System Design
Introduction to Clean Modern Architecture
Explore architecture through Point and workshop-session examples, SOLID, component cohesion, domain-centric patterns, and the future of code with generative AI.
- architecture
- clean-architecture
- object-oriented
- solid
- system-design

Software architecture is a topic I really enjoy. It is also a topic I need to revisit with each new experience. Reading a principle gives me one understanding of it. Applying it, making mistakes, and seeing the consequences gives me another.
This introduction brings together the ideas and examples from my Modern Architecture presentation and what I learned from Robert C. Martin’s Clean Architecture, alongside my experience through the years. We will follow the same progression: why architecture matters, how programming paradigms contribute to it, what SOLID and component principles add, and how those foundations connect to modern architectural patterns.
The staffing trends and Point examples draw on Clean Architecture, as used in the presentation. Our workshop-session example adapts the book’s SRP lesson to room usage and billing. All illustrations were created for DeftMove, with the underlying ideas attributed to their sources.
What is modern architecture?
When we call an architecture modern, what has changed? Our tools, platforms, and runtime environments have changed, along with expectations about availability, delivery speed, and teamwork.
To understand those changes, I want to begin with the foundations: why we need architecture and what makes one design useful.
My focus is software architecture: the structure of code, interactions between its parts, and decisions shaping those relationships. Solution architecture considers how systems and technologies deliver a solution; enterprise architecture considers capabilities and systems across an organization. These perspectives overlap, but we will start inside the software.
Architecture includes consequential decisions that can be expensive to change. Performance, reliability, security, and maintainability shape the structure alongside functional requirements, even when they are less visible to users.
One purpose of architecture is to reduce the effort needed to keep meeting business needs. Learning to do that requires continuing observation and correction.
The cost of “we can clean it up later”
Imagine a software product across successive releases: the engineering organization grows, while productivity declines and costs increase. These conceptual charts illustrate the pattern discussed in Clean Architecture; they do not represent DeftMove measurements or reproduce the book’s data.
First, the engineering staff grows substantially with each release:

A growing engineering team in our illustrative product scenario.
At the same time, the cost per line of code rises:

Increasing cost per line of code across the product’s releases.
Productivity moves in the opposite direction:

Productivity falls despite the growing engineering organization.
The monthly development payroll continues to increase:

Development payroll grows while the earlier chart shows declining productivity.
Lines of code are an incomplete measure of useful output. These trends illustrate a problem: adding people and spending more money does not necessarily make changes easier to deliver.
The problem begins with a familiar promise:
We can clean it up later; we just have to get to market first!
Later features build on today’s decisions. As the structure becomes harder to work with, each change takes more effort to understand and verify. The pressure to hurry then encourages more shortcuts.
Does the solution require a complete redesign?
A complete redesign can seem attractive. But a new codebase developed with the same habits can accumulate the same problems. What will change about how we design and maintain it?
Martin expresses the lesson as:
The only way to go fast, is to go well.
Today’s deadline matters. So does the structure that the next deadline will depend on.
A tale of two values: behavior and architecture
A software system provides stakeholders with two related kinds of value.
Behavior is what the software does. It implements requirements and produces results that users need.
Architecture allows the software to accommodate new requirements and determines the effort needed when behavior must change.
Imagine a program that satisfies every current requirement but is effectively impossible to change. Its usefulness declines as requirements evolve. Now imagine an incomplete program whose structure allows improvement: we have a way to make it useful and keep adapting it.
Architecture therefore has business value alongside visible behavior. Both need attention throughout development.
How do we measure good architecture?
Martin proposes examining the effort required to meet customer needs throughout a system’s life. If comparable changes remain manageable, the design helps. If ordinary changes become substantially harder with each release, it needs attention.
Ask how much code we must understand and modify, how much coordination is necessary, and how confidently we can verify the result. Ease of change matters alongside correctness, performance, and operational requirements.
Brian Foote and Joseph Yoder express the cost of these decisions this way:
If you think good architecture is expensive, try bad architecture.
Architecture versus design
Think about building a house to understand the relationship between architecture and design.
The overall structure determines how rooms connect, while smaller details determine whether that structure works. Each constrains the other.
Software has the same relationship: components connect to the functions, data structures, and interfaces inside them. High-level structure and low-level decisions form a continuum, even if we reserve architecture for decisions with the widest consequences.
Programming paradigms provide the building blocks for those larger structures.
Starting with the bricks: programming paradigms
Structured, functional, and object-oriented programming each introduce a discipline. In Martin’s framing, paradigms limit certain capabilities to make code easier to reason about.
| Paradigm | Discipline |
|---|---|
| Structured programming | Direct transfer of control |
| Functional programming | Assignment and mutation |
| Object-oriented programming | Indirect transfer of control through polymorphism |
These disciplines have architectural consequences, even though we first encounter them in ordinary functions and classes.
Structured programming and functional decomposition
Structured programming organizes control flow through sequence, selection, and iteration, limiting unrestricted goto jumps. We can follow an if statement or loop without tracing arbitrary transfers across the program.
This supports functional decomposition: breaking a large process into smaller functions whose behavior we can understand and test.
Decomposition organizes a problem into understandable responsibilities. Shorter functions alone do not establish sound architecture, but understandable units provide a foundation.
Functional programming: the squares example
The squares example compares Java, Clojure, and a functional style in C#. Each computes the squares of integers from 0 through 24.
In the imperative version, the loop repeatedly updates i:
public class Squint {
public static void main(String[] args) {
for (int i = 0; i < 25; i++) {
System.out.println(i * i);
}
}
}
The Clojure version expresses the transformation as a sequence:
(println (take 25 (map (fn [x] (* x x)) (range))))
It maps an unbounded sequence of integers to their squares and takes the first 25 results, without repeatedly assigning an application loop counter.
The same calculation can be expressed in C# with LINQ:
Enumerable.Range(0, 25)
.Select(n => n * n)
.ToList()
.ForEach(n => Console.WriteLine(n));
The second argument to Enumerable.Range is a count, so 25 matches the other versions. Java and C# print one result per line; Clojure prints the sequence. The values are identical.
The architectural distinction concerns state. Mapping computes each result without changing shared application state. Printing remains an effect at the edge of the calculation.
Immutability and architecture
Shared mutable state complicates concurrent software: operations may update the same state, and locks require coordination. Reducing mutation reduces the places where these problems arise.
Separating immutable calculations from state-changing operations can therefore guide component boundaries and update handling.
This connects to CQRS and event sourcing, which we will revisit. Neither eliminates concurrency problems: databases, external effects, and coordination still need explicit design.
Object-oriented programming and architecture
What gives object-oriented programming its architectural value? The familiar answer is encapsulation, inheritance, and polymorphism. We can examine each one through techniques that were also possible in C.
Encapsulation: the C and C++ Point examples
Consider this C interface:
// point.h
struct Point;
struct Point* makePoint(double x, double y);
double distance(struct Point* p1, struct Point* p2);
The header declares Point but hides its representation. Callers use pointers and exposed operations without seeing the members, which the implementation can define in point.c.
Now compare the C++ declaration:
// point.h
class Point {
public:
Point(double x, double y);
double distance(const Point& p) const;
private:
double x;
double y;
};
The C++ compiler enforces private access, but the representation remains visible in the header. Changes to that declaration can require callers to recompile.
This distinguishes access control from hiding representation. C can use an opaque pointer; C++ provides access controls. Both support encapsulation, with different consequences for dependencies between callers and implementations.
The architectural question is how much knowledge of one module’s implementation spreads into other modules.
Inheritance: Point and NamedPoint in C
We can extend the example with a named point:
// namedPoint.h
struct NamedPoint;
struct NamedPoint* makeNamedPoint(double x, double y, char* name);
void setName(struct NamedPoint* np, char* name);
char* getName(struct NamedPoint* np);
Its client code creates two named points and uses the existing distance operation:
struct NamedPoint* origin = makeNamedPoint(0.0, 0.0, "origin");
struct NamedPoint* upperRight = makeNamedPoint(1.0, 1.0, "upperRight");
printf("distance=%f\n",
distance((struct Point*) origin, (struct Point*) upperRight));
These interface and usage excerpts require a compatible implementation. In C, NamedPoint can contain a Point as its first member to support these casts. Casting unrelated structures merely because their fields look similar is unsafe. Allocation and cleanup also need definitions.
Object-oriented languages make inheritance and supported conversions more convenient, moving some of this manual work into compiler checks.
Polymorphism and plugin architecture
C programs can select behavior through function pointers, provided developers consistently follow conventions for initialization, dispatch, and object lifetime.
Object-oriented languages build many of these conventions into interfaces and virtual methods, making interchangeable implementations more convenient.
The architectural benefit is plugin architecture: the core expresses an operation, and another module implements it. We can change that implementation without rewriting the policy using it.
Dependency inversion: HL1, I, and ML1
The first dependency diagram shows a conventional calling tree. Main calls high-level modules, which call lower-level modules. The solid teal source dependencies follow the same direction as the dashed amber runtime calls:

Now introduce an interface, I, for the operation F():

The HL1, I, and ML1 example separates source dependency direction from runtime call direction.
HL1 depends on I. ML1 implements I. At runtime, a call from HL1 still reaches ML1.F(), but HL1 no longer needs a source dependency on the concrete ML1 module.
This is dependency inversion: runtime calls no longer dictate source dependency direction across a boundary.
UI and database as plugins to business rules
The next diagram applies the same idea to an application:

The UI and database depend on contracts associated with the business rules.
Business rules define the interfaces they need. UI and database code depend on those contracts and supply the mechanisms; the business-rule module need not import their implementations.
This supports separate development, testing, and replacement. Independent deployment also requires suitable packaging and compatible contracts; a dependency diagram alone cannot guarantee it.
The same control of dependency direction is central to Martin’s Clean Architecture description.
Why paradigms are not enough
Decomposition, controlled mutation, and dependency direction give us useful tools. We still need guidance on which functions and data belong together and how modules should relate.
Design principles help us make those decisions.
SOLID design principles and architecture
SOLID helps us organize functions and data into coherent classes and modules, then connect them into useful components.
The examples below show how these principles help structures tolerate change and remain understandable.
SRP: one reason to change, one actor
The Single Responsibility Principle says a module should be responsible to one actor: a coherent group of stakeholders whose needs drive its changes. It is often mistaken for a rule allowing only one operation per class.
A module can contain several operations supporting the same responsibility. Decomposing an individual function is a separate concern.
Conway’s law relates system structure to the designing organization’s communication structure. SRP similarly asks which people and policies give a module reasons to change.
The workshop-session example: accidental coupling
Consider a workshop venue that charges for room usage and reports how long its rooms are occupied. A WorkshopSession class combines three methods:
| Method | Actor driving its requirements |
|---|---|
calculateCharge() |
Finance, which sets billing and credit policies |
reportOccupancy() |
Venue operations, which tracks room-use minutes |
saveSession() |
Platform engineering, which manages storage |

One WorkshopSession class combines decisions driven by three different actors.
Initially, a 90-minute session means 90 billable minutes and 90 occupied minutes. Because calculateCharge() and reportOccupancy() need the same duration, the developers extract a shared countedMinutes() function:

The shared countedMinutes calculation couples billing and room-usage reporting.
Finance introduces a 15-minute introductory credit. A developer updates countedMinutes() to return 75, verifies the charge, and releases it. But the room was still occupied for 90 minutes.
Billing is correct, while venue operations now sees only 75 occupied minutes. Two independent policies shared one implementation because their calculations initially looked alike.
SRP helps us notice those independent reasons to change. Similar code does not always represent the same business responsibility.
Merge conflicts and separating responsibilities
Coordination problems can appear before behavior breaks. Developers changing billing, occupancy reporting, and persistence bring unrelated requests into the same module, creating difficult merges.
We can separate those responsibilities into UsageBilling, OccupancyReporter, and SessionStore. They operate on SessionData without depending on each other’s policy implementations:

Separate responsibilities can work with the same session data.
If callers need a convenient entry point, a SessionFacade can delegate to those separate responsibilities:

A facade provides a common entry point while the implementations remain separate.
Another variation keeps calculateCharge() in WorkshopSession and delegates reporting and saving. The appropriate arrangement depends on which responsibility that module is intended to own.
At component scale, this reasoning becomes the Common Closure Principle. At architectural scale, it identifies boundaries around independent reasons for change.
OCP: open for extension, closed for modification
The Open/Closed Principle asks us to support extensions while protecting existing code from unnecessary modification.
Component boundaries and dependency direction can protect higher-level policy so an extension does not force changes throughout the system.
The plugin diagrams illustrate this: implementations vary behind a contract the core understands. No system is closed to every change, so requirements and experience guide which variations to protect against.
LSP: the square and rectangle example
The Liskov Substitution Principle concerns whether one implementation can replace another while preserving the behavior callers rely on.
Mathematically, a square is a rectangle. But consider a mutable interface whose width and height change independently. A caller sets width to 5, then height to 4, expecting area 20.
A square implementation that changes both dimensions whenever either setter runs cannot preserve that contract:
| Operation | Mutable rectangle | Square that keeps its sides equal |
|---|---|---|
Set width to 5 |
Width becomes 5 |
Both sides become 5 |
Set height to 4 |
Height becomes 4, width stays 5 |
Both sides become 4 |
| Read area | 20 |
16 |
The problem is the independent-setter contract. Other square/rectangle models may be valid; the mathematical relationship is not in dispute.
LSP at the architectural level: taxi REST services
The same substitution problem can appear in an application integrating with taxi services through REST APIs.
Providers must satisfy a common contract. When one interprets it differently, the application needs special handling. Spreading those exceptions makes the integration harder to maintain.
Substitution includes the meaning of inputs, results, and failures. Keep provider-specific adaptation at a boundary so the application can rely on consistent behavior.
ISP: dependencies we do not need
The Interface Segregation Principle asks us to avoid forcing a client to depend on operations it does not use.
Unused functionality can still bring dependencies and reasons for change. Broad interfaces may force consumers to accommodate changes unrelated to their needs.
Keeping interfaces focused helps limit that unnecessary coupling. We will see the same concern in the Common Reuse Principle for components.
DIP: stable abstractions and changing details
The Dependency Inversion Principle returns us to HL1, I, and ML1: higher-level policy depends on a contract that concrete mechanisms implement.
Implementations can change behind an interface. Changing the interface may affect both callers and implementations, so important boundary contracts deserve stability.
Focus on volatile details that would force changes into higher-level policy. An interface around every concrete object or standard-library type is unnecessary.
Why SOLID is still not enough
Well-designed classes can still form a poorly organized system. We also need to decide how to group and release them as components.
In our building analogy, we have bricks, walls, and rooms. We still need to organize the building.
Component principles
A component groups software into a unit that can be distributed and consumed: a Java JAR, .NET DLL, or Ruby gem, for example. Components can ship together or participate in a plugin arrangement.
Three cohesion principles help decide which classes and modules belong together.
REP: the Reuse/Release Equivalence Principle
Consumers need identifiable releases. Version numbers, release notes, and change notifications let them decide whether and when to upgrade.
The classes in a release should represent a coherent capability. A shared version means less for an arbitrary collection of unrelated code.
REP connects the unit of reuse to the unit of release. Maven and Ruby’s package tooling support releases; we still choose the package’s contents.
CCP: the Common Closure Principle
CCP groups classes that change for the same reasons and at the same time. Different reasons to change suggest different components.
This applies SRP to components: concentrating a requirement change in fewer components can reduce review, validation, and release work.
Like OCP, CCP uses changes we have seen or reasonably expect to guide boundaries and contain their impact.
CRP: the Common Reuse Principle
CRP asks us to group classes that consumers use together, while avoiding dependencies on classes they do not need.
Consider a container and its associated iterators. They collaborate and are typically consumed together, so packaging them together makes sense.
Conversely, needing one class from a large component still creates a dependency on that component. Unrelated classes may bring libraries, release changes, and integration work.
This is the component-level counterpart of ISP: avoid making consumers depend on functionality outside their needs.
Balancing component cohesion
The principles create competing pressures. Developers may prefer keeping code together when it changes together; reusers may prefer smaller packages containing only what they consume.
Boundaries evolve as applications and consumers change. High cohesion requires balancing change patterns with reuse patterns.
Characteristics of modern architecture
These foundations help us identify six characteristics of modern architecture:
| Characteristic | What it means in practice |
|---|---|
| Keeping options open | Make consequential commitments deliberately and preserve useful alternatives where a boundary makes that possible. |
| High cohesion | Keep related responsibilities together within clear boundaries. |
| Autonomy | Support independent work, ownership, and versioning, with independent deployment where contracts and operations permit it. |
| Domain centricity | Organize important parts of the software around the business concepts and rules they represent. |
| Testability | Make it practical to verify behavior at the appropriate level without requiring every external dependency for every test. |
| Automation | Make building, checking, and releasing software repeatable. |
Polymorphism keeps implementation choices open. SRP and component cohesion locate responsibilities. Clear boundaries make isolated testing more practical.
Domain-centric architectures
Compare a database-centric arrangement with a domain-centric one:

The domain-centric arrangement places business concepts at the center and persistence among the surrounding mechanisms.
In the database-centric arrangement, business logic depends on data access. In the domain-centric arrangement, the domain forms the core; application behavior coordinates work, while infrastructure and presentation connect through boundaries.
Persistence remains important. The distinction is about which part of the software sets the terms for the other parts’ dependencies.
Hexagonal Architecture
Hexagonal Architecture, also called Ports and Adapters, expresses the application through its interactions with the outside world. Ports define meaningful application interactions. Adapters connect those interactions to a UI, another application, a database, or a test implementation.

DeftMove illustration of Ports and Adapters, based on Alistair Cockburn’s Hexagonal Architecture.
A graphical interface and an automated test can drive the same core behavior through different adapters. Cockburn’s original Hexagonal Architecture article explains this separation.
Onion Architecture
Onion Architecture places the domain model at the center, with surrounding layers of domain and application behavior. UI and infrastructure sit toward the outside:

DeftMove illustration of Jeffrey Palermo’s Onion Architecture, showing the layers from outside to inside.
Source dependencies point toward the center, where interfaces can be defined for outer implementations. This is the dependency inversion we examined earlier. See Palermo’s introduction to Onion Architecture.
Clean, Hexagonal, and Onion Architecture protect business behavior from unnecessary dependencies on external mechanisms, with differing terminology and emphasis.
CQRS and event sourcing
These three diagrams show how CQRS and event sourcing relate.
CQRS with a shared database
CQRS stands for Command Query Responsibility Segregation. Commands express changes to the system. Queries retrieve information. CQRS allows the models serving these two responsibilities to differ.

Separate command and query responsibilities can share a database.
In this diagram, commands pass through domain behavior and persistence, while queries use a path suited to retrieving data. Separating those responsibilities does not require separate databases.
CQRS with separate read and write databases
The second diagram separates the stores as well:

A separate read store needs a mechanism to reflect changes from the write side.
Separate stores support different access needs but require synchronization. With asynchronous updates, readers may temporarily see older data, which the application must accommodate.
The choice depends on the problem. Fowler’s CQRS explanation discusses both the model separation and the additional complexity it can introduce.
Event sourcing and the event store
In the third diagram, the write side stores events:

With event sourcing, stored events provide the history from which state and read projections can be built.
Event sourcing records state-changing events as the authoritative history for the part of the system using the pattern. Applying those events lets us reconstruct state or build a read projection.
Historical events can remain immutable while new events extend the history. Concurrent writes, event versioning, and replay effects still need design. Fowler’s Event Sourcing article explores these implications.
CQRS and event sourcing can work together, but neither requires the other. Separating reads and writes differs from choosing events as the authoritative record.
Domain-driven design
Domain-driven design starts with understanding the domain and choosing model boundaries: strategic design. Tactical techniques, such as entities and value objects, help implement models within those boundaries.
Three concepts are especially useful here:
- A subdomain is a part of the business problem we are trying to understand.
- A bounded context defines where a particular model and its meanings apply consistently.
- A ubiquitous language is the shared vocabulary that domain experts and engineers use when discussing and implementing that model.
A subdomain describes the problem space; a bounded context defines a model boundary in the solution. They are related, not interchangeable.
These ideas guide responsibilities before packaging and deployment. A bounded context can exist within one application or a distributed system.
Microservices
For microservices, we can connect architectural structure to six concerns:
| Concern | Architectural concern |
|---|---|
| High cohesion | A clear business responsibility and closely related behavior |
| Autonomy | Independent changes and deployment, supported by ownership and versioning |
| Business domain focus | Boundaries that represent business functions or domain areas |
| Resilience | Anticipating failures and defining appropriate recovery or degraded behavior |
| Observability | Understanding system health through useful logs and monitoring |
| Automation | Repeatable testing, integration, and deployment |
Network boundaries add failure and coordination work. Each service needs a meaningful responsibility, compatible interactions, and operational support to run independently.
Team autonomy depends on ownership, contracts, and data decisions. Counting services tells us little about how independently teams can change them.
The modern architecture ecosystem
The practices and platforms around the code also influence its architecture:
- Agile and Waterfall: when we seek feedback and how we respond to changing requirements influence when architectural decisions get tested and revised.
- CI/CD: frequent integration and automated delivery help teams verify changes and release them consistently.
- Cloud: the platform offers infrastructure capabilities while introducing choices about availability, scaling, cost, and operations.
- Containerization and orchestration: containers package runtime environments, while orchestration coordinates their deployment and operation.
- DevOps: collaboration across development and operations connects architectural decisions to how the system actually behaves in use.
- Multiple architectural styles: different parts of a system can use different approaches when their responsibilities and constraints justify them.
These practices support testability, autonomy, and automation. Code structure and the delivery environment continually influence each other.
Case-study discussion: Skype, Amazon, and Uber
For the Skype, Amazon, and Uber case studies in my presentation, ask what requirements shaped each system at a specific stage, which boundaries it used, and what tradeoffs prompted later changes. Connect those decisions to ownership, cohesion, dependencies, and operations.
Apply the same questions in a code walkthrough: follow calls and source dependencies, identify the actors behind changes, and examine how components are built and tested. That brings these concepts into everyday engineering.
The future of code in the era of generative AI and frontier models
Generative AI and frontier models do not remove the need for architecture. A small infrastructure change can show whether our boundaries actually work.
The problem: replacing a SQL client changes too much
I expect use cases to depend on repository interfaces, and repository implementations to depend on a SQL-client interface. With those contracts and their behavior preserved, replacing the SQL library should affect its client adapter and the wiring that selects it.
If that replacement instead requires widespread changes to repositories and use cases, it is a warning that the boundaries may not be doing their job. Client-specific types or behavior may have leaked through the interfaces. Having interfaces and dependency injection does not, by itself, mean we have implemented dependency inversion or Clean Architecture correctly.
Leaving a model to make these decisions without accurate rules can spread the same mistake across many files. The result may run while becoming harder to change.
The solution: explicit rules, bounded agents, and checks
Before delegating the change, make the architectural rules concrete:
- Define the boundaries. Use cases know repository contracts; repositories know the SQL-client contract. Library-specific types stay inside the client adapter.
- Give agents a bounded task. Replace the adapter while preserving the contracts. Require review before expanding the change into other layers.
- Enforce the rules. Use dependency checks to catch forbidden imports, and contract and integration tests to verify that the replacement preserves behavior.
- Review independently. Check the diff against the intended scope and requirements, including why each file needed to change.
This is the governance I want around coding agents: written constraints, automated checks, and clear ownership of decisions. I would not rely on unconstrained “vibe coding” for a large production project. AI can help implement a design; engineers remain responsible for keeping that design coherent.
References
The presentation draws on these resources:
- Robert C. Martin, Clean Architecture: A Craftsman’s Guide to Software Structure and Design.
- Modern Software Architecture: Domain Models, CQRS, and Event Sourcing, Pluralsight.
- Clean Architecture: Patterns, Practices, and Principles, Pluralsight.
- Microservices Architecture, Pluralsight.
The linked original articles in the relevant sections provide further detail on the architecture patterns and their terminology.