Chapter 11: Systems

clean-code systems dependency-injection architecture aop

Status: Notes complete
Difficulty: Medium-Hard
Time to complete: ~55 min read


Overview

A city works because it has teams of people managing different parts — the water department, the power company, traffic control — each maintaining their own slice of complexity behind clear interfaces. No single person understands the whole city. Yet cities function because each subsystem is well-separated and individually manageable.

Software systems should work the same way. The key insight of this chapter: building a system (construction) and using a system (runtime logic) are fundamentally different activities, and conflating them is a primary source of bugs, tight coupling, and unmaintainable code.

Chapter 11 covers:

  • Separating object construction from object use
  • The “separation of main” idiom
  • Factories for controlled construction
  • Dependency Injection (DI) — manual and container-based
  • Scaling up incrementally
  • AOP and decorators for cross-cutting concerns
  • Domain-Specific Languages (DSL)
  • Test-driving your architecture

The Problem: What Bad Code Looks Like

The most common violation: constructing dependencies inline inside a class that also uses them.

// BAD — OrderProcessor both constructs and uses its dependencies
public class OrderProcessor {
    private final InventoryService inventoryService;
    private final PaymentGateway paymentGateway;
    private final AuditLogger auditLogger;
 
    public OrderProcessor() {
        // Construction mixed with business logic setup.
        // Now OrderProcessor is tightly coupled to every concrete class.
        // Unit testing is impossible without running real services.
        this.inventoryService = new InventoryServiceImpl("jdbc:mysql://prod:3306/inv");
        this.paymentGateway = new StripePaymentGateway(System.getenv("STRIPE_KEY"));
        this.auditLogger = new DatabaseAuditLogger("jdbc:mysql://prod:3306/audit");
    }
 
    public OrderResult process(Order order) {
        auditLogger.log("Processing order: " + order.getId());
        inventoryService.reserve(order.getItems());
        return paymentGateway.charge(order.getTotal());
    }
}

What’s wrong here:

  • Every test instantiation of OrderProcessor hits a MySQL database and Stripe API
  • Changing the InventoryService implementation requires modifying OrderProcessor
  • The class has two jobs: building its world, and doing its work
  • Configuration (connection strings, API keys) is scattered and duplicated across constructors

Core Principles

1. Separate Constructing a System from Using It

Why this rule exists: Construction logic (connecting to databases, reading config, wiring objects together) and runtime logic (processing orders, computing discounts) operate at completely different levels of abstraction. A class that does both violates the Single Responsibility Principle at the architectural level. Mixing them makes classes hard to test, hard to replace, and hard to understand.

The rule: a class should receive fully-constructed dependencies, not construct them.

// GOOD — OrderProcessor receives its dependencies; it has no idea how they were built
public class OrderProcessor {
    private final InventoryService inventoryService;
    private final PaymentGateway paymentGateway;
    private final AuditLogger auditLogger;
 
    public OrderProcessor(
            InventoryService inventoryService,
            PaymentGateway paymentGateway,
            AuditLogger auditLogger) {
        this.inventoryService = inventoryService;
        this.paymentGateway = paymentGateway;
        this.auditLogger = auditLogger;
    }
 
    public OrderResult process(Order order) {
        auditLogger.log("Processing order: " + order.getId());
        inventoryService.reserve(order.getItems());
        return paymentGateway.charge(order.getTotal());
    }
}

The construction happens somewhere else entirely — in main, in a factory, or in a DI container.

C++ equivalent:

// GOOD — constructor injection in C++17
class OrderProcessor {
public:
    OrderProcessor(
        std::unique_ptr<InventoryService> inventory,
        std::unique_ptr<PaymentGateway> payment,
        std::unique_ptr<AuditLogger> logger)
        : inventoryService_(std::move(inventory)),
          paymentGateway_(std::move(payment)),
          auditLogger_(std::move(logger)) {}
 
    OrderResult process(const Order& order) {
        auditLogger_->log("Processing order: " + order.id());
        inventoryService_->reserve(order.items());
        return paymentGateway_->charge(order.total());
    }
 
private:
    std::unique_ptr<InventoryService> inventoryService_;
    std::unique_ptr<PaymentGateway>   paymentGateway_;
    std::unique_ptr<AuditLogger>      auditLogger_;
};

Python equivalent:

# GOOD — constructor injection with type hints
from __future__ import annotations
from abc import ABC, abstractmethod
 
class OrderProcessor:
    def __init__(
        self,
        inventory_service: InventoryService,
        payment_gateway: PaymentGateway,
        audit_logger: AuditLogger,
    ) -> None:
        self._inventory = inventory_service
        self._payment = payment_gateway
        self._logger = audit_logger
 
    def process(self, order: Order) -> OrderResult:
        self._logger.log(f"Processing order: {order.id}")
        self._inventory.reserve(order.items)
        return self._payment.charge(order.total)

2. Separation of Main

Why this rule exists: main is the natural starting point of a program — it is the one place that is explicitly in charge of setup. If all object construction flows through main (or a small bootstrap module), then the rest of the application has no construction knowledge at all. The application just uses the objects it is given.

This creates a clean one-way dependency arrow: main → application code. The application never knows about main.

// GOOD — main constructs the entire object graph; application code knows nothing about it
public class Main {
    public static void main(String[] args) {
        // All construction happens here
        AuditLogger logger = new DatabaseAuditLogger(
            System.getenv("DB_URL"), System.getenv("DB_USER"), System.getenv("DB_PASS")
        );
        InventoryService inventory = new InventoryServiceImpl(
            new JdbcInventoryRepository(System.getenv("DB_URL"))
        );
        PaymentGateway payment = new StripePaymentGateway(System.getenv("STRIPE_KEY"));
 
        OrderProcessor processor = new OrderProcessor(inventory, payment, logger);
 
        // Hand off to application code — no construction beyond this point
        OrderController controller = new OrderController(processor);
        HttpServer server = new HttpServer(controller);
        server.start(8080);
    }
}

The object graph is built once, at startup. After main passes objects into the application, the application has no way of reaching back into main. This is enforced structurally, not by convention.


3. Factories for Controlled Construction

Why this rule exists: Sometimes the application must control when an object is created, not just how. For example, an OrderProcessor might need to create a new ShippingLabel each time an order is fulfilled — not once at startup. We still want the construction details out of OrderProcessor.

The Abstract Factory pattern solves this: the application calls a factory interface it knows, and the factory implementation (wired up in main) does the actual construction.

// GOOD — Abstract Factory pattern in Java
public interface ShippingLabelFactory {
    ShippingLabel createLabel(Order order, Carrier carrier);
}
 
// Production implementation (wired in main)
public class FedExShippingLabelFactory implements ShippingLabelFactory {
    private final FedExApiClient client;
 
    public FedExShippingLabelFactory(FedExApiClient client) {
        this.client = client;
    }
 
    @Override
    public ShippingLabel createLabel(Order order, Carrier carrier) {
        return client.generateLabel(order.getShippingAddress(), carrier.getCode());
    }
}
 
// Test implementation (used in unit tests)
public class FakeShippingLabelFactory implements ShippingLabelFactory {
    @Override
    public ShippingLabel createLabel(Order order, Carrier carrier) {
        return new ShippingLabel("FAKE-LABEL-" + order.getId());
    }
}
 
// OrderFulfillmentService uses the factory interface — never touches FedEx directly
public class OrderFulfillmentService {
    private final ShippingLabelFactory labelFactory;
 
    public OrderFulfillmentService(ShippingLabelFactory labelFactory) {
        this.labelFactory = labelFactory;
    }
 
    public void fulfill(Order order) {
        ShippingLabel label = labelFactory.createLabel(order, Carrier.FEDEX);
        label.print();
        order.markShipped();
    }
}

C++ equivalent:

// GOOD — Abstract Factory in C++17
class ShippingLabelFactory {
public:
    virtual ~ShippingLabelFactory() = default;
    virtual std::unique_ptr<ShippingLabel> createLabel(
        const Order& order, const Carrier& carrier) = 0;
};
 
class FedExShippingLabelFactory : public ShippingLabelFactory {
public:
    explicit FedExShippingLabelFactory(std::shared_ptr<FedExApiClient> client)
        : client_(std::move(client)) {}
 
    std::unique_ptr<ShippingLabel> createLabel(
        const Order& order, const Carrier& carrier) override {
        return client_->generateLabel(order.shippingAddress(), carrier.code());
    }
 
private:
    std::shared_ptr<FedExApiClient> client_;
};

Python equivalent:

# GOOD — Abstract Factory in Python with ABC
from abc import ABC, abstractmethod
 
class ShippingLabelFactory(ABC):
    @abstractmethod
    def create_label(self, order: Order, carrier: Carrier) -> ShippingLabel:
        ...
 
class FedExShippingLabelFactory(ShippingLabelFactory):
    def __init__(self, client: FedExApiClient) -> None:
        self._client = client
 
    def create_label(self, order: Order, carrier: Carrier) -> ShippingLabel:
        return self._client.generate_label(order.shipping_address, carrier.code)
 
class FakeShippingLabelFactory(ShippingLabelFactory):
    def create_label(self, order: Order, carrier: Carrier) -> ShippingLabel:
        return ShippingLabel(f"FAKE-LABEL-{order.id}")

4. Dependency Injection (DI)

Why this rule exists: DI is the practical implementation of “separate construction from use.” A class declares what it needs (via constructor, setter, or method parameters) and never calls new on its dependencies. Something external — main, a factory, or a DI container — satisfies those declarations. This is the most powerful technique in the chapter.

There are three injection styles:

Constructor injection — dependencies are passed at construction time; the object is fully usable immediately. Preferred.

Setter injection — dependencies are set after construction; allows optional dependencies and circular graphs (use sparingly).

Method injection — a dependency is passed as a method argument; used when a dependency is only needed for one specific operation.

Java — Manual DI

// GOOD — Manual constructor injection; no framework needed
ReportRepository reportRepo = new JdbcReportRepository(dataSource);
EmailService emailService = new SmtpEmailService(smtpConfig);
ReportScheduler scheduler = new ReportScheduler(reportRepo, emailService);
scheduler.runDailyReports();

Java — Spring DI with @Autowired (container-managed)

// GOOD — Spring manages construction; ReportScheduler just declares what it needs
@Service
public class ReportScheduler {
    private final ReportRepository reportRepository;
    private final EmailService emailService;
 
    // Constructor injection — Spring resolves and injects the implementations
    @Autowired
    public ReportScheduler(ReportRepository reportRepository, EmailService emailService) {
        this.reportRepository = reportRepository;
        this.emailService = emailService;
    }
 
    @Scheduled(cron = "0 0 6 * * *")
    public void runDailyReports() {
        List<Report> reports = reportRepository.findDueForToday();
        reports.forEach(report -> emailService.send(report.toEmail()));
    }
}

C++ — Manual constructor injection

// GOOD — Manual DI in C++17
auto dataSource    = std::make_shared<PostgresDataSource>(connStr);
auto reportRepo    = std::make_unique<JdbcReportRepository>(dataSource);
auto emailService  = std::make_unique<SmtpEmailService>(smtpConfig);
auto scheduler     = std::make_unique<ReportScheduler>(
                         std::move(reportRepo), std::move(emailService));
scheduler->runDailyReports();

Python — Manual DI with type hints

# GOOD — Manual DI; no framework needed; fully testable
data_source    = PostgresDataSource(conn_str)
report_repo    = JdbcReportRepository(data_source)
email_service  = SmtpEmailService(smtp_config)
scheduler      = ReportScheduler(report_repo, email_service)
scheduler.run_daily_reports()
 
# In tests, swap the real implementations for fakes:
scheduler_for_test = ReportScheduler(
    FakeReportRepository(),
    FakeEmailService()
)

5. Scaling Up

Why this rule exists: You cannot build the right full-scale system on day one. No one can. Trying to predict all requirements up front leads to over-engineering — unnecessary abstractions that slow development without delivering value. The insight: if your architecture is clean and decoupled, you can start small and grow incrementally.

Software systems, unlike physical buildings, can be refactored as they grow — but only if the architecture stayed clean. A big ball of mud cannot be incrementally extended; it can only be rewritten.

The practical implication: invest in clean separation of concerns now (DI, interfaces, small classes), and your system will be extensible later without a rewrite. Concrete architectural concerns — sharding strategy, caching layers, async message queues — can be deferred until you have real data proving you need them.


6. AOP / Decorators for Cross-Cutting Concerns

Why this rule exists: Some concerns — logging, auditing, transactions, security, caching — cut across many classes and have nothing to do with the business logic those classes implement. If you add transaction handling inline inside every service method, you have tangled concerns that are hard to change, test, or replace. Aspect-Oriented Programming (AOP) externalizes cross-cutting concerns into separate modules (aspects, decorators, middlewares) that are applied declaratively.

Java — Spring AOP for transaction management

// BAD — Transaction management tangled into business logic
public class OrderService {
    public void placeOrder(Order order) {
        Connection conn = dataSource.getConnection();
        conn.setAutoCommit(false);
        try {
            inventoryService.reserve(order.getItems());  // business logic
            paymentService.charge(order.getTotal());     // business logic
            conn.commit();
        } catch (Exception e) {
            conn.rollback();
            throw new RuntimeException(e);
        } finally {
            conn.close();
        }
    }
}
 
// GOOD — @Transactional externalizes the concern; placeOrder reads as pure business logic
@Service
public class OrderService {
    @Transactional
    public void placeOrder(Order order) {
        inventoryService.reserve(order.getItems());
        paymentService.charge(order.getTotal());
    }
}

Python — Decorator for cross-cutting concerns

# GOOD — Decorator externalizes audit logging; business methods stay clean
import functools
import logging
from typing import Callable, TypeVar, ParamSpec
 
P = ParamSpec("P")
R = TypeVar("R")
 
def audit_log(func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        logging.info(f"Calling {func.__name__} with args={args[1:]}")
        result = func(*args, **kwargs)
        logging.info(f"{func.__name__} completed successfully")
        return result
    return wrapper
 
class OrderService:
    @audit_log
    def place_order(self, order: Order) -> OrderResult:
        # Pure business logic — no logging noise
        self._inventory.reserve(order.items)
        return self._payment.charge(order.total)

C++ — Decorator pattern for cross-cutting concerns

// GOOD — Logging decorator wraps InventoryService without touching its implementation
class InventoryService {
public:
    virtual ~InventoryService() = default;
    virtual void reserve(const std::vector<Item>& items) = 0;
};
 
class LoggingInventoryService : public InventoryService {
public:
    explicit LoggingInventoryService(std::unique_ptr<InventoryService> wrapped)
        : wrapped_(std::move(wrapped)) {}
 
    void reserve(const std::vector<Item>& items) override {
        std::cout << "[AUDIT] Reserving " << items.size() << " items\n";
        wrapped_->reserve(items);
        std::cout << "[AUDIT] Reservation complete\n";
    }
 
private:
    std::unique_ptr<InventoryService> wrapped_;
};

7. Test-Drive the System Architecture

Why this rule exists: The best architectures emerge from feedback — real usage, real data, real bottlenecks. If you test-drive your architecture (build a simple structure first, prove it works, then extend), you make better decisions than if you design the entire system on a whiteboard before writing a line of code.

The principle is not “no architecture up front” — it is “defer irreversible decisions until you have information to make them well.” Use proofs of concept. Measure. Then commit.

Practical approach:

  1. Start with a simple layered architecture (controller → service → repository)
  2. Write integration tests that drive the end-to-end behavior
  3. Identify real bottlenecks from load data, not speculation
  4. Extract services, add caching, introduce async processing where measurements justify it

8. Domain-Specific Languages (DSL)

Why this rule exists: There is always a communication gap between domain experts (who speak in business terms) and code (which speaks in computer terms). A well-designed DSL minimizes that gap — it lets domain concepts be expressed directly in code, reducing misunderstandings and making the code readable to non-programmers.

DSLs do not have to be full languages. Even a fluent API is a form of internal DSL.

Internal DSL examples (fluent builder APIs):

// GOOD — Java Criteria API / Specification pattern as internal DSL
List<Product> results = productRepository.findAll(
    where(category("ELECTRONICS"))
        .and(priceBelow(500.00))
        .and(inStock())
        .orderBy(popularity().descending())
);
 
// vs BAD — raw SQL strings in business logic
List<Product> results = jdbcTemplate.query(
    "SELECT * FROM products WHERE category = 'ELECTRONICS' " +
    "AND price < 500 AND stock_qty > 0 ORDER BY sales_rank DESC",
    productRowMapper
);
# GOOD — Python internal DSL for report configuration (using method chaining)
report = (
    ReportBuilder()
    .for_period(start=date(2025, 1, 1), end=date(2025, 12, 31))
    .group_by(Dimension.REGION, Dimension.PRODUCT_CATEGORY)
    .include_metrics(Metric.REVENUE, Metric.UNITS_SOLD, Metric.MARGIN)
    .filter(region="EMEA")
    .format(ReportFormat.EXCEL)
    .build()
)
// GOOD — C++ builder DSL for HTTP request construction
auto request = HttpRequest::Builder()
    .method(HttpMethod::POST)
    .url("https://api.payments.io/v2/charges")
    .header("Authorization", "Bearer " + token)
    .body(payload.toJson())
    .timeout(std::chrono::seconds(30))
    .build();

External DSL examples (separate languages):

  • Gradle (build configuration DSL)
  • Cucumber/Gherkin (test specification DSL: Given / When / Then)
  • Flask/Express routing DSL (@app.route("/orders/<id>", methods=["GET"]))

Comparison / Summary Table

DI Approaches

ApproachCouplingTestabilityWhen to Use
new in constructor (hardcoded)HighNone — cannot substitute fakesNever in production code
Setter injectionMediumGood — can set fakes after constructionOptional dependencies; circular graphs
Constructor injectionLowExcellent — all deps visible at constructionDefault choice for all required deps
DI container (Spring, Guice)LowestExcellent — container wires everythingLarge applications; many collaborators

Cross-Cutting Concern Techniques

LanguageTechniqueExample
JavaSpring AOP (@Transactional, @Cacheable)Transaction management, caching
JavaDynamic proxies (java.lang.reflect.Proxy)Custom aspect logic
PythonFunction decorators (@functools.wraps)Audit logging, retry logic, timing
C++Decorator pattern (inheritance + composition)Logging wrappers, security checks

When to Apply / Common Exceptions

Apply separation of construction from use:

  • Any class with more than 1-2 collaborators
  • Any class you want to unit test (always)
  • Any class whose dependencies might change (database, payment provider)

Exception — simple scripts and utilities: A 50-line script that reads a CSV file and prints a report does not need a DI container. The overhead is not worth it.

Apply AOP / decorators:

  • When the same cross-cutting concern (logging, auth, transactions) appears in 3 or more places
  • When the cross-cutting concern is not the primary responsibility of the class

Exception: Do not wrap every method in a decorator out of habit. Only externalize a concern when it is genuinely orthogonal to the business logic.

Defer architectural decisions:

  • Until you have real data about usage patterns
  • Until you have measured an actual bottleneck

Exception: Some architectural decisions are hard to reverse (database choice, API contract). Make those early but with prototypes and proofs of concept, not pure speculation.


Checklist

  • Does each class receive its dependencies rather than constructing them?
  • Is all object graph construction isolated to main, a bootstrap module, or a DI container?
  • Are factories used when the application must control when (not just how) objects are created?
  • Are cross-cutting concerns (logging, transactions, security) handled outside of business classes?
  • Can every service class be unit-tested without hitting a database, file system, or network?
  • Does the codebase use fluent APIs or DSLs where domain terminology benefits readability?
  • Are architectural decisions deferred until measurements support them?

Key Takeaways

  1. Construction and use are different concerns — a class should do one, not both. The new keyword belongs in factories, main, or DI configuration.
  2. Separation of main moves all object graph construction into one place; the application has no knowledge of how it was assembled.
  3. Abstract Factory gives the application control over when objects are created while keeping construction details external.
  4. Constructor injection is the default form of DI — all dependencies are visible at construction, the object is always fully usable, and tests can substitute fakes trivially.
  5. DI containers (Spring, Guice) automate wiring for large applications but are not required — manual DI is often sufficient.
  6. Cross-cutting concerns (logging, auth, caching, transactions) should live in aspects, decorators, or middleware — not inline in business classes.
  7. Good architectures emerge — start simple, test-drive, measure, then extend. Do not over-architect before you have data.
  8. DSLs minimize the gap between domain language and code — even a fluent builder API is a DSL worth investing in.

  • ch10-classes — SRP and cohesion; the classes that DI connects
  • ch09-unit-tests — Why DI is essential for testability
  • ch03-functions — Small, focused functions are the unit that systems are built from
  • ch13-concurrency — Cross-cutting concerns become even more important in concurrent systems

Last Updated: 2026-04-14