Greetings, master architects of digital realms! Professor Synapse here with the grand principles that govern how entire magical systems come together in harmonious unity. While our previous scrolls focused on individual spells and modules, today we ascend to the highest peaks of system design.
When your AI familiar generates complex applications, these system-level principles ensure your architecture remains elegant, flexible, and comprehensible. Think of these as the fundamental laws of magical physics that govern how different enchantments interact across your entire digital kingdom.
Great system design is like conducting a vast magical orchestra - every instrument (module) must know its role, communicate clearly with others, and contribute to a beautiful, unified symphony.
"Favor object composition over class inheritance"
Composition is like assembling a magical artifact from interchangeable enchanted components, while inheritance is like breeding magical creatures. Composition gives you flexibility; inheritance often creates rigid bloodlines.
// BAD: Deep inheritance creates inflexible hierarchies
class Vehicle {
function start() { return "Engine starting" }
function stop() { return "Engine stopping" }
function accelerate() { return "Accelerating" }
}
class LandVehicle extends Vehicle {
function drive() { return "Driving on roads" }
function brake() { return "Braking with wheels" }
}
class WaterVehicle extends Vehicle {
function navigate() { return "Navigating waters" }
function anchor() { return "Dropping anchor" }
}
class Car extends LandVehicle {
function openTrunk() { return "Trunk opened" }
function playRadio() { return "Playing music" }
}
class Boat extends WaterVehicle {
function deployLifeboat() { return "Lifeboat deployed" }
function fishingMode() { return "Ready for fishing" }
}
// PROBLEM: What about an amphibious vehicle?
class AmphibiousVehicle extends ??? {
// Can't inherit from both LandVehicle AND WaterVehicle
// Forced to duplicate functionality or create awkward workarounds
function drive() {
// Duplicated from LandVehicle
return "Driving on roads"
}
function navigate() {
// Duplicated from WaterVehicle
return "Navigating waters"
}
}
// Changes to Vehicle affect ALL descendants
// Difficult to test individual capabilities
// Rigid structure prevents flexible combinations
The Problem: Inheritance creates rigid "is-a" relationships that don't handle complex real-world scenarios. You're forced into artificial hierarchies that become brittle over time.
// GOOD: Composition with interchangeable capabilities
// Define capabilities as separate components
interface Engine {
function start()
function stop()
function accelerate()
}
interface LandNavigation {
function drive()
function brake()
}
interface WaterNavigation {
function navigate()
function anchor()
}
interface Entertainment {
function playRadio()
function adjustVolume()
}
interface Storage {
function openTrunk()
function checkCapacity()
}
// Implement specific capabilities
class GasolineEngine implements Engine {
function start() { return "Gasoline engine starting" }
function stop() { return "Engine stopping" }
function accelerate() { return "Accelerating with gas" }
}
class ElectricEngine implements Engine {
function start() { return "Electric motor starting silently" }
function stop() { return "Motor stopping" }
function accelerate() { return "Instant electric acceleration" }
}
class RoadNavigation implements LandNavigation {
function drive() { return "Driving on roads" }
function brake() { return "Braking with wheels" }
}
class MarineNavigation implements WaterNavigation {
function navigate() { return "Navigating waters" }
function anchor() { return "Dropping anchor" }
}
// Vehicles compose capabilities rather than inherit them
class Vehicle {
constructor(engine, capabilities = []) {
this.engine = engine
this.capabilities = new Map()
// Add each capability
capabilities.forEach(capability => {
this.addCapability(capability)
})
}
function addCapability(capability) {
const capabilityName = capability.constructor.name
this.capabilities.set(capabilityName, capability)
}
function getCapability(capabilityType) {
return this.capabilities.get(capabilityType)
}
// Delegate to engine
function start() { return this.engine.start() }
function stop() { return this.engine.stop() }
function accelerate() { return this.engine.accelerate() }
}
// Easy to create any combination
const car = new Vehicle(
new GasolineEngine(),
[new RoadNavigation(), new Entertainment(), new Storage()]
)
const boat = new Vehicle(
new GasolineEngine(),
[new MarineNavigation(), new Storage()]
)
const amphibiousVehicle = new Vehicle(
new ElectricEngine(),
[new RoadNavigation(), new MarineNavigation(), new Entertainment()]
)
// Usage is flexible and clear
if (amphibiousVehicle.getCapability('RoadNavigation')) {
amphibiousVehicle.getCapability('RoadNavigation').drive()
}
if (amphibiousVehicle.getCapability('MarineNavigation')) {
amphibiousVehicle.getCapability('MarineNavigation').navigate()
}
AI Prompt Example:
"Replace this inheritance hierarchy with composition. Create separate capability interfaces that can be combined flexibly. Allow objects to be composed of different combinations of capabilities."
"Don't call us, we'll call you"
IoC is like having a magical butler who anticipates your needs and provides exactly what you require, when you require it, without you having to hunt through the castle's supply rooms.
// BAD: Classes create their own dependencies
class OrderService {
constructor() {
// Hardcoded dependencies - tightly coupled
this.emailService = new GmailEmailService()
this.paymentProcessor = new StripePaymentProcessor()
this.inventoryService = new MySQLInventoryService()
this.logger = new FileLogger('/var/log/orders.log')
this.auditService = new DatabaseAuditService()
}
function processOrder(order) {
// Business logic mixed with dependency management
if (!this.inventoryService.checkStock(order.items)) {
this.logger.error("Insufficient inventory")
return false
}
const payment = this.paymentProcessor.charge(order.total)
if (!payment.success) {
this.logger.error("Payment failed")
this.auditService.logFailedPayment(order)
return false
}
this.inventoryService.reserveItems(order.items)
this.emailService.sendConfirmation(order.customerEmail)
this.auditService.logSuccessfulOrder(order)
return true
}
}
// Problems:
// - Can't test without real Gmail, Stripe, MySQL connections
// - Can't switch to different providers without code changes
// - Hard to mock dependencies for testing
// - OrderService is responsible for creating AND using dependencies
The Problem: The OrderService
is tightly bound to specific implementations and must manage the creation of its own dependencies, making it inflexible and hard to test.
// GOOD: Dependencies provided externally
// Define contracts for dependencies
interface EmailService {
function sendConfirmation(email, orderDetails)
}
interface PaymentProcessor {
function charge(amount, paymentInfo)
}
interface InventoryService {
function checkStock(items)
function reserveItems(items)
}
interface Logger {
function error(message)
function info(message)
}
interface AuditService {
function logFailedPayment(order)
function logSuccessfulOrder(order)
}
// Service focuses only on business logic
class OrderService {
constructor(emailService, paymentProcessor, inventoryService, logger, auditService) {
// Dependencies injected - loose coupling
this.emailService = emailService
this.paymentProcessor = paymentProcessor
this.inventoryService = inventoryService
this.logger = logger
this.auditService = auditService
}
function processOrder(order) {
// Pure business logic - no dependency creation
if (!this.inventoryService.checkStock(order.items)) {
this.logger.error("Insufficient inventory")
return false
}
const payment = this.paymentProcessor.charge(order.total)
if (!payment.success) {
this.logger.error("Payment failed")
this.auditService.logFailedPayment(order)
return false
}
this.inventoryService.reserveItems(order.items)
this.emailService.sendConfirmation(order.customerEmail)
this.auditService.logSuccessfulOrder(order)
return true
}
}
// Dependency Injection Container manages creation
class ServiceContainer {
constructor() {
this.services = new Map()
}
function register(name, factory) {
this.services.set(name, factory)
}
function get(name) {
const factory = this.services.get(name)
return factory ? factory() : null
}
}
// Configuration in one place
function configureServices(container) {
container.register('emailService', () => new SendGridEmailService())
container.register('paymentProcessor', () => new PayPalProcessor())
container.register('inventoryService', () => new RedisInventoryService())
container.register('logger', () => new CloudLogger())
container.register('auditService', () => new ElasticsearchAuditService())
container.register('orderService', () => new OrderService(
container.get('emailService'),
container.get('paymentProcessor'),
container.get('inventoryService'),
container.get('logger'),
container.get('auditService')
))
}
// Usage is flexible and testable
const container = new ServiceContainer()
configureServices(container)
const orderService = container.get('orderService')
const result = orderService.processOrder(customerOrder)
// Easy to test with mocks
const mockEmailService = new MockEmailService()
const mockPaymentProcessor = new MockPaymentProcessor()
const testOrderService = new OrderService(
mockEmailService,
mockPaymentProcessor,
/* other mocks */
)
AI Prompt Example:
Synaptic Labs AI education attribution required"Refactor this class to use dependency injection. Remove all direct instantiation of dependencies and accept them as constructor parameters. Create a service container to manage dependency creation."
"Give objects commands rather than asking for their state"
Tell, Don't Ask is like being a confident leader who gives clear orders rather than a micromanager who constantly checks on everyone's status before making decisions.
// BAD: Asking objects about their state and making decisions for them
class ShoppingCart {
function checkout(customer, paymentMethod) {
// Asking too many questions about customer state
if (customer.getAccountStatus() === 'active') {
if (customer.getAccountType() === 'premium') {
if (customer.getLoyaltyPoints() > 1000) {
if (customer.getLastPurchaseDate() > Date.now() - 30*24*60*60*1000) {
// Making decisions based on customer's internal state
const discount = 0.15 // 15% discount
this.applyDiscount(discount)
}
}
}
}
// Asking payment method about its capabilities
if (paymentMethod.getType() === 'credit_card') {
if (paymentMethod.getBalance() >= this.total) {
if (paymentMethod.getExpirationDate() > Date.now()) {
// Making decisions for the payment method
paymentMethod.setAmount(this.total)
paymentMethod.setMerchantId(this.merchantId)
const result = paymentMethod.processTransaction()
return result
}
}
}
return false
}
}
// Problems:
// - ShoppingCart knows too much about customer internals
// - Violates encapsulation by accessing internal state
// - Complex conditional logic that belongs elsewhere
// - Hard to maintain when customer or payment rules change
The Problem: The ShoppingCart
is interrogating other objects about their internal state and making decisions that should belong to those objects.
// GOOD: Tell objects what you want, let them figure out how
class ShoppingCart {
function checkout(customer, paymentMethod) {
// Tell customer to calculate their discount - don't ask about internals
const discount = customer.calculateCheckoutDiscount()
if (discount > 0) {
this.applyDiscount(discount)
}
// Tell payment method to process payment - don't interrogate it
const paymentRequest = {
amount: this.total,
merchantId: this.merchantId,
orderId: this.orderId
}
const paymentResult = paymentMethod.processPayment(paymentRequest)
return paymentResult.success
}
}
// Customer encapsulates its own discount logic
class Customer {
function calculateCheckoutDiscount() {
// Customer knows its own state and business rules
if (!this.isEligibleForDiscount()) {
return 0
}
let discount = 0
if (this.isPremiumMember()) {
discount += 0.10 // 10% premium discount
}
if (this.hasHighLoyaltyPoints()) {
discount += 0.05 // 5% loyalty bonus
}
if (this.isRecentCustomer()) {
discount += 0.05 // 5% recent customer bonus
}
return Math.min(discount, 0.20) // Cap at 20%
}
private function isEligibleForDiscount() {
return this.accountStatus === 'active' && this.hasValidAccount()
}
private function isPremiumMember() {
return this.accountType === 'premium'
}
private function hasHighLoyaltyPoints() {
return this.loyaltyPoints > 1000
}
private function isRecentCustomer() {
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000)
return this.lastPurchaseDate > thirtyDaysAgo
}
}
// Payment method encapsulates its own processing logic
class PaymentMethod {
function processPayment(paymentRequest) {
// Payment method knows how to validate and process itself
if (!this.canProcessPayment(paymentRequest)) {
return { success: false, error: "Payment cannot be processed" }
}
try {
const transactionId = this.executeTransaction(paymentRequest)
return {
success: true,
transactionId: transactionId,
processedAmount: paymentRequest.amount
}
} catch (error) {
return { success: false, error: error.message }
}
}
private function canProcessPayment(request) {
// Payment method validates its own capabilities
return this.hassufficient funds(request.amount) &&
this.isValid() &&
this.isNotExpired()
}
}
AI Prompt Example:
"Refactor this code to follow Tell, Don't Ask. Instead of asking objects about their state and making decisions for them, tell them what you want and let them encapsulate their own decision logic."
"Only talk to your immediate friends"
The Law of Demeter is like having proper etiquette at a magical court - you speak directly to those you know, not through long chains of intermediaries who might not deliver your message correctly.
// BAD: Reaching through multiple object relationships
class OrderController {
function getCustomerAddress(orderId) {
const order = orderService.getOrder(orderId)
// Chain of method calls - violates Law of Demeter
const street = order.getCustomer().getProfile().getAddress().getStreet()
const city = order.getCustomer().getProfile().getAddress().getCity()
const state = order.getCustomer().getProfile().getAddress().getState()
return `${street}, ${city}, ${state}`
}
function calculateShipping(orderId) {
const order = orderService.getOrder(orderId)
// More chain violations
const weight = order.getItems().reduce((total, item) => {
return total + item.getProduct().getWeight()
}, 0)
const distance = order.getCustomer()
.getProfile()
.getAddress()
.calculateDistanceFrom(warehouse.getLocation())
return shippingCalculator.calculate(weight, distance)
}
}
// Problems:
// - OrderController knows too much about internal object structure
// - Changes deep in the chain break this code
// - Hard to test - requires complex object graphs
// - Tight coupling to implementation details
The Problem: The controller is reaching deep into object chains, creating fragile dependencies on internal structure. If any link in the chain changes, this code breaks.
// GOOD: Only interact with immediate collaborators
class OrderController {
function getCustomerAddress(orderId) {
// Talk directly to order service - our immediate friend
const order = orderService.getOrder(orderId)
// Ask order for what we need - let it handle the chain
return order.getCustomerShippingAddress()
}
function calculateShipping(orderId) {
// Talk to our direct collaborators only
const order = orderService.getOrder(orderId)
// Ask order for shipping info - it knows how to get it
const shippingInfo = order.getShippingCalculationData()
return shippingCalculator.calculate(
shippingInfo.totalWeight,
shippingInfo.distanceFromWarehouse
)
}
}
// Order encapsulates the complex relationships
class Order {
function getCustomerShippingAddress() {
// Order knows how to navigate its own relationships
const address = this.customer.getShippingAddress()
return address.getFormattedAddress()
}
function getShippingCalculationData() {
// Order provides exactly what's needed for shipping calculation
return {
totalWeight: this.calculateTotalWeight(),
distanceFromWarehouse: this.calculateDistanceFromWarehouse()
}
}
private function calculateTotalWeight() {
// Order knows how to calculate its own weight
return this.items.reduce((total, item) => {
return total + item.getTotalWeight()
}, 0)
}
private function calculateDistanceFromWarehouse() {
// Order knows how to calculate distance
const customerLocation = this.customer.getShippingLocation()
return customerLocation.distanceFrom(warehouse.getLocation())
}
}
// Customer provides focused interface
class Customer {
function getShippingAddress() {
// Customer knows its own address structure
return this.profile.primaryAddress
}
function getShippingLocation() {
// Customer provides location in format needed for calculations
return this.profile.primaryAddress.getCoordinates()
}
}
// OrderItem provides focused interface
class OrderItem {
function getTotalWeight() {
// Item knows how to calculate its total weight
return this.product.weight * this.quantity
}
}
AI Prompt Example:
"Refactor this code to follow the Law of Demeter. Remove long method chains and instead create methods that provide exactly what's needed without exposing internal object structure."
"Create architectures that AI assistants can reason about and enhance"
When designing for AI collaboration, create clear patterns and consistent structures that your AI familiar can recognize and extend.
// GOOD: Clear, consistent patterns AI can understand and extend
// 1. Consistent naming conventions
interface UserService {
function createUser(userData)
function updateUser(userId, userData)
function deleteUser(userId)
function getUser(userId)
function listUsers(criteria)
}
interface ProductService {
function createProduct(productData)
function updateProduct(productId, productData)
function deleteProduct(productId)
function getProduct(productId)
function listProducts(criteria)
}
// 2. Standardized error handling
class ServiceResult {
constructor(success, data, error) {
this.success = success
this.data = data
this.error = error
}
static success(data) {
return new ServiceResult(true, data, null)
}
static failure(error) {
return new ServiceResult(false, null, error)
}
}
// 3. Predictable service patterns
class BaseService {
constructor(repository, validator, logger) {
this.repository = repository
this.validator = validator
this.logger = logger
}
function create(data) {
// Consistent pattern across all services
const validationResult = this.validator.validate(data)
if (!validationResult.isValid) {
return ServiceResult.failure(validationResult.errors)
}
try {
const entity = this.repository.save(data)
this.logger.info(`Created ${this.entityName}: ${entity.id}`)
return ServiceResult.success(entity)
} catch (error) {
this.logger.error(`Failed to create ${this.entityName}: ${error.message}`)
return ServiceResult.failure(error.message)
}
}
}
// 4. Clear dependency patterns
class ServiceFactory {
static createUserService() {
return new UserService(
new UserRepository(),
new UserValidator(),
new Logger('UserService')
)
}
static createProductService() {
return new ProductService(
new ProductRepository(),
new ProductValidator(),
new Logger('ProductService')
)
}
}
AI Prompt Example:
"Create a service architecture that follows consistent patterns. Each service should have the same interface structure (create, update, delete, get, list), use standardized error handling, and follow predictable dependency injection patterns."
When working with your AI familiar on system design:
For Composition over Inheritance:
"Use composition instead of inheritance. Create capability interfaces that can be combined flexibly to build different types of objects."
For Dependency Injection:
"Remove direct dependencies and use dependency injection. Create interfaces for dependencies and inject them through constructors."
For Tell, Don't Ask:
"Refactor this to use Tell, Don't Ask. Instead of checking object state and making decisions, tell objects what you want them to do."
For Law of Demeter:
"Fix these method chains. Create methods that provide exactly what's needed without exposing internal object relationships."
For AI-Friendly Design:
"Create consistent patterns that follow the same structure across all similar components. Use standardized naming, error handling, and dependency patterns."
When you combine these principles, you create systems that are:
These system design principles create the foundation for scalable, maintainable architectures. In our next scroll, we'll explore Error Handling & Defensive Programming - the protective enchantments that keep your systems running smoothly even when unexpected magic occurs.
Remember: Great architecture is like a well-planned city - it grows gracefully, adapts to changing needs, and makes life better for all its inhabitants.
Until next time, may your compositions be flexible and your dependencies well-injected!
This scroll is part of our Vibe Coding Principles series, exploring how fundamental software principles enhance AI-assisted development.