Theory summary
Think about a house. Before the builders lay a single brick, someone draws a plan: where the walls go, where the pipes run, which rooms sit next to each other. That plan is hard to change later. Moving a wall is easy on paper and very expensive once the house is built. Software architecture is the plan of a program. It is the set of big decisions that give the program its shape and that cost a lot to change afterwards.
Most programs start small and grow. If nobody thinks about the shape, the code turns into a tangled ball of string: pull one thread and ten others move. A good architecture keeps the pieces separate, so a change in one place does not break the others. This matters in real life every day. It is why a shop can change its payment company without rewriting the whole website, and why a phone app can work with one database today and another tomorrow.
In this summary you will learn what architecture is and how we judge it, then a family of styles that answer one question: how do we keep the business rules (the rules that make the program useful, like "a cart total is the sum of its items") safe from the details (the database, the web framework, the screen)? You will meet layers, the hexagon, the clean architecture and its dependency rule, one program against many small services, events, and the vocabulary of domain-driven design. At the end you will see why every choice is a trade-off, and how teams write their decisions down.
What architecture is and quality attributes
Imagine you build a toy city with blocks. Some choices are cheap to undo: the colour of one roof. Other choices are expensive: where the main road goes, because every house is placed around it. In software, the architecture is made of the expensive choices. Authors such as Martin Fowler and Ralph Johnson describe it as the set of design decisions that matter, the ones that are hard to change. It also lives in the shared understanding of the team, not only in a document.
Robert Martin adds that there is no clear line between architecture and detailed design. The small decisions hold up the big ones, so it is one continuum. He also says what the goal is: to minimise the people and effort needed to build and maintain the system. A good architecture keeps the cost of each new change low during the whole life of the program. One way to do that is to keep options open, which means not choosing a detail too early.
Functional and non-functional requirements
A functional requirement says what the program does, such as "a customer can place an order". A non-functional requirement says how well it does it: how fast, how often it is up, how safe, how easy to change. Individual parts implement the functions, but the non-functional properties depend on the architecture as a whole. This is why two online stores with the same buttons can need very different architectures: one for 50 users in an office, another for 5 million users around the world that must be up 99.99% of the time.
Quality attributes need a measurable scenario
A quality attribute is a word like performance, availability, security or maintainability. "The system must be fast" is not useful, because no test can fail it. A good requirement is a scenario: under which condition, which response is measured, and what the limit is. For example: "with 1,000 users at once, 95 out of 100 searches answer in under 300 milliseconds". It describes the result we want, not the technical solution.
| Attribute | What it asks of the structure |
|---|---|
| Performance | Critical work kept in a few large parts that talk to each other as little as possible |
| Security | Layers, with the most valuable data in the innermost one |
| Availability | Redundancy: spare copies that take over when one part fails |
| Maintainability | Small parts that are easy to replace |
Look at the table: performance wants few big parts, maintainability wants many small ones. They pull in opposite directions, so an architecture is always a compromise between the attributes that matter most for that system.
Information hiding
David Parnas had a simple idea in the 1970s: split the program into modules so that each module hides one decision that is likely to change, like how the data is stored. Other modules only know a small, stable interface (the list of things the module offers). Then, when the decision changes, the change stays inside one module. If six files read the same global list of orders and expect it sorted by date, changing that list breaks six files. That is the opposite of hiding.
Views, folders and teams
One drawing cannot show everything. Kruchten's 4+1 view model uses four views: logical (the key ideas), process (what runs at the same time), development (the modules the programmers handle) and physical (which machines run what). The "+1" is a set of scenarios that ties the four together. The view about processors and machines is the physical one.
Robert Martin also talks about a screaming architecture. The plan of a library building "screams" that it is a library. In the same way, the top folders of a program should scream what the program does (orders, payments), not which framework it uses. If the folders say only controllers, models and views, the framework is deciding the shape.
Screams the framework Screams the business
src/ src/
controllers/ orders/
models/ payments/
views/ shipping/
helpers/ catalogue/Conway's law (1968) says that organisations build systems whose structure copies how the organisation talks. Three teams writing a compiler tend to make a compiler with three passes. It describes what usually happens, it is not a design rule. The inverse Conway manoeuvre uses it on purpose: first arrange the teams the way you want the architecture, then let them build it. Drawing new services on paper while keeping the old teams usually brings back the old split under new names.
↑ Back to the contentsLayered and hexagonal architectures
Think of a school. The pupil asks the teacher, the teacher asks the secretary, and the secretary looks in the archive. Nobody jumps from the pupil straight to the archive. That is the layered architecture: the program is cut into levels, from the closest to the user down to the closest to the data. Each layer offers services to the layer above it and uses only the services of the layer just below. Because of that, a layer can be replaced without disturbing the others, as long as its interface stays the same.
+------------------------+
| Presentation | screens, web pages
+------------------------+
| uses
v
+------------------------+
| Business | rules
+------------------------+
| uses
v
+------------------------+
| Persistence | database access
+------------------------+Layer and tier are different words
A layer is a logical division: a group of code with one job, like "presentation". A tier is a physical division: where the code runs, for example the browser, the application server and the database server. You can have three layers running on a single machine, or two layers spread over five machines. The two counts do not depend on each other.
Strict and relaxed layering
In strict (closed) layering a layer calls only the layer right below it. This isolates changes, but sometimes a layer has methods whose only job is to pass the call on. In relaxed (open) layering a layer may call any layer below it. That removes the useless forwarding, but raises coupling (how much one part depends on another), because more layers now depend on the same lower layer.
When most requests cross a layer that does nothing but forward them, we call it the sinkhole anti-pattern (an anti-pattern is a common bad habit). A few such cases are normal. When they are the majority, rethink the layers or open some of them, accepting the extra coupling.
Hexagonal architecture: ports and adapters
Alistair Cockburn had another picture. Think of a laptop with sockets on its sides. The laptop does not care what you plug in, as long as the plug fits the socket. In the hexagonal architecture (also called ports and adapters) the application is at the centre. It knows only ports: interfaces that the application itself declares, saying what it needs or what it offers. Each outside technology connects through an adapter, a piece that translates between the port and the technology. The word port here has nothing to do with network ports.
HTTP controller --+ +-- PostgreSQL adapter
Command line ---+--> [PORT] <--+
Automated test ---+ APPLICATION +-- In-memory adapter
driving adapters driven adapters
(start the call) (are called)Why a hexagon? Only because it is a shape with many sides. Cockburn wanted to show many equivalent ports, with no "top" and "bottom" as in the layers picture. What matters is the difference between the inside (the application) and the outside (the adapters).
| Kind of adapter | Who starts the call? | Examples |
|---|---|---|
| Driving (primary) | The outside world calls the application | HTTP controller, command-line tool, an automated test |
| Driven (secondary) | The application calls the outside through a port it declared | Repository, e-mail sender, clock |
A port can have many adapters: the real database, one that keeps data in memory, a test double. Since the application depends only on the port, the adapter is chosen from outside. So the business rules can be tested without a database, and the real adapter is tested separately.
The big difference from classic layers
In the three classic layers, dependencies point downward, so the business layer depends on persistence and ends up shaped by the database. In the hexagonal architecture the core declares the port, and persistence implements it. The calls still go from the core to the database, but the source-code dependency points toward the core.
// Layered: the business imports the database layer
import { NoteTable } from "../persistence/note-table";
// Hexagonal: the core imports only its own port,
// and the database adapter imports the port
import type { NoteRepository } from "./ports/note-repository";Ivar Jacobson's BCE pattern has the same spirit with three kinds of object: boundary (talks to the actors outside), control (coordinates the steps of one use case) and entity (holds data and rules of the domain). It is one of the influences of the clean architecture, together with the hexagonal one and DCI: entity matches entities, control matches use cases, boundary matches interface adapters. If the class that reads the HTTP request also holds the sequence of the flow, it mixes boundary and control.
↑ Back to the contentsClean architecture and the dependency rule
Picture an onion. In the middle lies the most precious part, and each skin around it protects it. Robert Martin's clean architecture draws the program as circles inside circles. From the centre outward: entities (the most general business rules), use cases (rules of this particular application), interface adapters (controllers, presenters, repositories) and, outside, frameworks and drivers (the web framework, the database, the tools). The further in, the higher the level of the policy.
+----------------------------------------+
| Frameworks and drivers |
| +----------------------------------+ |
| | Interface adapters | |
| | +----------------------------+ | |
| | | Use cases | | |
| | | +----------------------+ | | |
| | | | Entities | | | |
| | | +----------------------+ | | |
| | +----------------------------+ | |
| +----------------------------------+ |
+----------------------------------------+
every dependency arrow points inwardThe dependency rule
The dependency rule says: source-code dependencies point only inward. Code in an inner circle knows no name from an outer circle, not a class, not a function, not a type. As a result, the business rules do not change when you swap the database or the web framework.
Martin calls the database and the web details. They are ways to bring data in, send it out and store it, but they are not the reason the system exists. The rules are at the centre, and the details connect from the outside, like plug-ins. This lets you postpone and replace them. It also gives the main characteristics Martin lists: independence of frameworks, of the user interface and of the database, and rules that can be tested without any of them.
Being independent of frameworks does not mean never using one. It means the business rules do not depend on it. The framework stays in the outermost circle and is treated as a tool. Also, the outer code changes often and the inner code rarely, and because dependencies point inward, the thing that changes often depends on the thing that changes rarely, never the other way round.
Calls go out, dependencies go in
Here is the puzzle. A use case must save a note in the database, so at run time it calls code in an outer circle. Does this break the rule? No, because the flow of control and the source-code dependency are different things. The solution is dependency inversion (the D of SOLID, a set of five design principles). The use case declares an interface of its own, the port NoteRepository. The outer class PostgresNoteRepository implements it. So the call goes outward, but the source code dependency points inward.
Order of calls at run time:
NoteController
-> CreateNote
-> PostgresNoteRepository
-> database
Where each name is declared:
adapters/ NoteController
use-cases/ CreateNote, NoteRepository (interface)
drivers/ PostgresNoteRepository (implements it)| Word | What it is |
|---|---|
| Dependency inversion | A principle: high-level code and low-level code both depend on an abstraction |
| Dependency injectionan object receives what it needs from outside, usually in the constructor | A mechanism that delivers the real object to the one that needs it |
| IoC container | An optional tool that creates and connects objects automatically |
Finding a violation
To check the rule, read the import lines of each file and compare the layer of the importer with the layer of the imported. Importing something further in is allowed, even skipping circles. Importing something further out is the violation. A type is a name too. In TypeScript an import type vanishes when compiled, but the coupling stays in the source, because a change in the outer type breaks the inner file. Outer names can also hide without an import line: the global Response type or an HTTP status code in a use case already ties it to the web.
// use-cases/create-note.ts
import { Note } from "../entities/note"; // inward: allowed
import { PostgresNoteRepository }
from "../drivers/postgres-note-repository"; // outward: VIOLATIONBecause the rule is about source code, a machine can check it without running the program. A script or a lint rule maps each folder to a layer and rejects any import from an inner layer to an outer one. Run in the build or in continuous integration (the automatic checks on every change), it stops the violation before the code is merged.
↑ Back to the contentsEntities and use cases
Think of a restaurant. Some rules belong to the food itself: a salad has a vegetable in it. Other rules belong to how this restaurant works: orders are taken in this order, then the kitchen is told. The first kind lives in the entities, the second in the use cases.
In clean architecture, the entity sits in the innermost circle and holds the most general business rules, the ones that would hold even if the work were done by hand on paper. It depends on no database, framework or screen. For that reason it changes little and can serve more than one application of the same company. A use case (also called an interactor) holds a rule that is specific to the application: the sequence of steps of one operation, such as "create a note". It orchestrates the entities and reaches the database, the clock or the network only through ports that it declares itself. It does not know who calls it, or where the data is stored.
| Question | Domain rule (entity) | Application rule (use case) |
|---|---|---|
| Holds in | Any application using the concept | This application only |
| Example | A title cannot be empty or longer than 80 characters | A user cannot have two notes with the same title |
| Needs other data? | No, it looks at the object alone | Often yes: it checks the notes already stored |
| Changes | Rarely | More often |
Entities keep the closed rules that define the concept. A rule that is a decision of one product, that may still change, or that needs to look at other stored data goes in a use case. Then the entity stays reusable and the rule remains easy to find in the application code. Protocol, pagination and tables are details of outer layers, not rules at all.
Value objects that cannot be wrong
A value object is a small object defined only by its values, like a Title. If its constructor is private and the only way in is a factory method create, then nobody can build an invalid one. Whoever receives a Title as a parameter can trust it, and the rule is written in a single place. The invalid state becomes impossible to represent.
export class Title {
private constructor(public readonly value: string) {}
static create(raw: string): Result<InvalidTitleError, Title> {
const trimmed = raw.trim();
if (trimmed.length === 0 || trimmed.length > 80) {
return fail(new InvalidTitleError(raw));
}
return ok(new Title(trimmed));
}
}Notice that create returns a Result (also called Either): either a success with the value or a failure with an error. Expected errors, like a bad title, are part of the contract of the operation, so a Result shows them in the signature and forces the caller to decide what to do. Exceptions are kept for failures nobody expected, like a database that is down.
The anemic domain model
Martin Fowler named an anti-pattern: the anemic domain model. The domain classes only hold data, and all the rules live in separate procedural services around them. The central idea of object orientation, keeping data and rules together, is lost. The usual result is duplicated rules and objects that can end up in an invalid state.
What crosses the boundary
The input and the output of a use case are simple data structures, with no behaviour, defined by the use case layer itself. The controller turns the request into that input, and the presenter turns the output into what the screen needs. That way neither the framework nor the entity leaks to the other side. A use case also should not know if it was called by HTTP, a command line or a test: it returns a result in application terms, and each adapter translates it. Status codes, SQL and console.log do not belong in it.
Ports for time and randomness
The current time and random values change at every run. Code that reads them directly (new Date(), crypto.randomUUID()) is not deterministic: it does not give the same answer for the same input. So the use case receives an IdGenerator and a Clock as ports. In tests we hand it fixed versions, in production the real ones, and the use case never changes.
A port belongs to the code that uses it. That is why NoteRepository is declared next to the use cases, and PostgresNoteRepository implements it in an outer layer. A test of CreateNote can then use an in-memory repository, a fake clock and a fake id generator, with no server and no database. It runs in milliseconds and checks the application rule alone. SQL, routes and wiring are checked by the tests of each adapter.
Interface adapters
When you travel to another country, you may carry a plug adapter, so your charger fits a different wall socket. The interface adapters layer does the same job for data. It sits between the use cases and the outside world and converts data from the form that is best for use cases and entities into the form that is best for the web or the database, and back. Controllers, presenters and gateways live here.
request -> Controller -> input data -> Use case
|
output data
v
screen <- View <- view model <- Presenter| Piece | Job | What it must not do |
|---|---|---|
| Controller | Takes an outside request, extracts the data, calls the use case, passes the result on | Decide business rules, touch the database, know display details |
| Presenter | Turns the output of the use case into a view model: formatted texts, flags, labels | Decide what the business rule is |
| View | Copies the view model values to the screen or terminal | Hold any logic |
| Gateway or repository | Translates between storage format and entities | Be imported by the use case |
A good controller is thin: it is simple to test and simple to replace. The same use case output can have different presenters, for example one for HTTP and another for the command line.
The humble object
Some code is hard to test, like drawing pixels on a screen. The Humble Object pattern splits the behaviour in two. The hard-to-test part goes into a "humble" object with as little logic as possible. Everything else goes into an ordinary object that is easy to test. In clean architecture the view is the humble object, and the presenter is the testable part. Because the presenter already prepared every text and flag, the view only copies them.
Keeping the framework out
If the controller received the context object of the web framework, the adapters layer would depend on a detail of the outer layer. Instead, the adapters declare plain request and response types. The framework converts its own context to them and calls the controller. The controller can then be tested with plain object literals. For the same reason, neither the framework request nor a database row is passed inward: that would make the inner layer know something about the outer one. The controller picks out the fields and builds the input structure.
// adapters/http.ts: types of our own, no framework import
export type HttpRequest = { body: unknown; params: Record<string, string> };
export type HttpResponse = { statusCode: number; body: unknown };Translating errors
A use case says what happened with a domain or application error, and names no protocol. For example, RemoveNote returns NoteNotFound. Deciding that this means HTTP status 404 is the adapter's work: invalid input becomes 400, a missing resource becomes 404, an unexpected failure becomes 500. A command-line adapter would translate the same errors into other things, like a message and an exit code.
Two kinds of validation
There are two checks, and they live in different places. Syntactic validation asks if the outside input has the expected shape: are the fields there, are the types right? It happens at the boundary, where a schema library like Zod can be used. Business validation protects the invariants of the domain, for example that a title is not blank and has at most 80 characters. It lives in entities and value objects, so it holds on every input path, not just through the web.
Where does the repository live?
The interface NoteRepository belongs to the use-case layer, which defines what it needs. The implementation is an adapter. In the layering presented by Otávio Lemos, an adapter that talks directly to a driver or an external library, such as the PostgreSQL client, is placed in the outermost layer, where importing the library is allowed. The interface adapters layer is then left free of external dependencies. The use case must never import the adapter file, that is the flaw to avoid.
Frameworks, drivers and the composition root
In a house, the walls and rooms are the plan, but the electric sockets, the taps and the paint are things you can change. The outermost circle of clean architecture holds these replaceable things: the web framework, the database driver, the input and output libraries, and the small glue code that connects them to the adapters. Usually little code of your own is written there. Martin calls this circle details, because nothing in it defines what the system does, only how it is delivered and stored.
He advises keeping the framework "at arm's length". A framework is written by people who do not know your problem and have no commitment to it. If your entities and use cases extend the framework's base classes, the commitment goes one way: your code depends on the framework, and a new version or a replacement means rewriting the centre. Kept at the edge, behind adapters, the framework is used without the system marrying it.
The composition root
Someone has to create the real objects and plug them together. Think of the electrician who connects everything in a new house: he must know all the rooms, but no room needs to know him. This place is the composition root (Martin calls it Main). It is the entry point of the program, the dirtiest and outermost component. It knows every layer and every tool, so nothing may depend on it. Concentrating the assembly in one place lets the rest of the code depend only on abstractions.
main/ (composition root)
reads config
new PostgresNoteRepository(url) --+
new UuidGenerator() --+--> new CreateNote(...)
new SystemClock() --+
hands the ready object to the entry pointsConstructor injection
With constructor injection, a class says what it needs in its constructor and receives a ready object. If a class instead creates its dependency with new, or fetches it from a service locator (a global shelf where objects are looked up), it is stuck with one implementation and hides that link from the people who use it. With injection, the dependency is visible in the signature and can be replaced in tests.
// A: hidden, tied to PostgreSQL
private readonly notes = new PostgresNoteRepository();
// B: visible, replaceable
constructor(private readonly notes: NoteRepository) {}The swap experiment
A good test of the whole design: add a new class SqliteNoteRepository that implements the port. Which existing code must change so the application runs with it? If everything inside depends on ports and the assembly is in factories of the composition root, the answer is only the composition root: a factory reads the configuration at start-up and chooses the implementation. If another file has to change, there is undue coupling somewhere.
Configuration and secrets
Environment variables, like MAX_NOTES, are a detail of the runtime environment. So they are read at the edge, by the composition root, which validates and converts them once and passes them inward as plain values through constructors. The inner layers never touch process.env, and a test configures a use case just by passing another number.
Configuration that changes per environment, and above all secrets such as a database password, stays out of the code and out of the repository. The real .env file is listed in .gitignore. A file like .env.example is committed, with the variable names and obviously fake values. A newcomer then knows what to configure, and no credential stays in the history.
ORMs
An ORM (object-relational mapper) is a tool that converts table rows into objects. It is a persistence detail and belongs at the edge. Decorators on the entity, or an entity that inherits from an ORM class (the Active Record style), make the centre depend on the tool. The compatible alternative is the Data Mapper style: a separate schema in the outer layer, and a repository that translates between rows and entities.
↑ Back to the contentsMonolith against microservices
A monolith is like a big lunch box: everything is in one box, and you carry it all together. In software, a monolith is a system delivered as a single deployable unit (one thing you release as a whole). The word is about how the system is packaged and released, not how the code is organised inside. So a monolith can be well organised, and a group of services can be a mess.
A microservice is a service that can be deployed on its own, owns its data and is built around one business capability, such as payments or catalogue. Its size in lines of code is not part of the definition. The useful question is: can I change and release this service without asking the others?
| Feature | Monolith | Microservices |
|---|---|---|
| Release | One unit, all together | Each service on its own |
| Data | Usually one shared database | Each service has its own database |
| Calls between parts | Local function calls | Network calls, which can fail or be slow |
| Good at | Simplicity, easy changes at the start | Independent teams and independent scaling |
| Main risk | A tangled ball of code | The costs of a distributed system |
Database per service
If two services read and write the same tables, the database schema becomes a contract between them that nobody wrote down. Any structural change has to be agreed and released together, and that cancels independent deployment. This is why each service keeps its own data and shows it to others only through its API or through events.
Modular monolith and distributed monolith
The modular monolith has a single deployment with strong boundaries inside: each module is a business area, exposes a public interface and does not let others reach its details. It gives much of the order of microservices without the cost of a distributed system. If a service must be taken out one day, the boundary already exists.
The opposite mistake is the distributed monolith. The system was cut into services, but they are still tightly tied: they must be released together, share a database or form long chains of synchronous calls. It has the problems of both worlds. A practical test: can one service be released alone? If every release needs all six services at once, in a fixed order, it is a distributed monolith.
Monolith first
The advice associated with Martin Fowler is to start with a monolith. At the beginning of a product nobody knows yet where the right boundaries are. In a monolith, fixing a wrong boundary is a local refactoring (reorganising code without changing what it does). Between services, the same fix means changing network contracts, migrating data and coordinating releases.
Cut by business, not by layer
Services should be cut by business capability (or bounded context), not by technical layer. A feature usually touches every layer: a field in the form, a rule, a column. If the services are ui, business-logic and data-access, one feature changes three services and three teams. If they are catalogue, ordering and payment, what changes together stays together.
By layer (bad) By business (better)
ui-service catalogue-service
business-logic-service ordering-service
data-access-service payment-service
shipping-serviceThe network is not a function call
A local call finishes or fails together with its caller. A network call has a third result: unknown. If a request times out, maybe the other side did the work and only the answer was lost. This is partial failure, and "the network is reliable" is one of the classic fallacies of distributed systems. Retrying blindly can charge a card twice. The defence is to make the operation idempotent (doing it twice has the same effect as once), for example by sending an idempotency key on every attempt.
Chains of synchronous calls also hurt availability, because the request needs every service at the same time, so the availabilities are multiplied. With each service up 99% of the time and independent failures:
- 1 service
- 99 %
- 2 in a chain
- 98.01 %
- 3 in a chain
- 97.03 %
- 6 in a chain
- 94.15 %
For three services the math is 0.99 x 0.99 x 0.99 = 0.970299, about 97.03%. To avoid long chains, teams use asynchronous communication, caching or degraded answers.
Sagas
Placing an order must reserve stock in one service and charge a card in another, each with its own database. There is no single transaction across both. A saga splits the operation into local transactions, one per service. If a step fails, earlier steps are undone with compensating actions: new transactions with the opposite effect, like releasing the reservation or refunding the charge. Between start and end the system is temporarily inconsistent. This is called eventual consistency.
↑ Back to the contentsEvent-driven architecture and CQRS
At school, a bell rings and everybody knows what to do, though the bell does not talk to anyone one by one. In an event-driven architecture, parts of a system announce that something happened, and other parts react. The announcer does not need to know who listens, so the coupling is low.
Event or command?
An event is the news of a fact that already happened, so it is named in the past tense: OrderPlaced. Its publisher does not send it to anyone in particular. A command is a request in the imperative: PlaceOrder. It is aimed at one handler, which may accept or refuse it. The difference is in the intent of the message, not in the transport.
Topic or queue?
| Feature | Publish/subscribe topic | Point-to-point queue |
|---|---|---|
| Who gets a message? | Every subscriber gets its own copy | Only one of the competing consumers |
| Used for | Telling many parties about the same fact | Sharing work |
| Adding more consumers | More reactions to the same event | More throughput |
Delivery guarantees and idempotency
Brokers (the programs that carry messages) often promise at-least-once delivery: no message is lost, but some may arrive more than once, for example when the consumer's acknowledgement does not reach the broker. So the consumer must be idempotent: processing a message twice must give the same result as once. The usual technique is to store the identifier of every processed event and ignore repeats.
async function onOrderPaid(event: OrderPaid) {
// Without a check, a repeated event adds the points twice
if (await alreadyProcessed(event.eventId)) return;
await addPoints(event.customerId, event.points);
await markProcessed(event.eventId);
}Order in a partitioned log
Convention: a partitioned log such as Apache Kafka. A topic has several partitions, and the message key picks the partition. The order guarantee holds per partition, not for the whole topic. To keep the events of one account in order (AccountOpened, MoneyDeposited, AccountClosed), give them all the same key, such as the account id, so they go to the same partition. Events of different accounts are processed in parallel with no defined order between them.
Choreography or orchestration?
In a choreography, services react to each other's events and none knows the whole flow, like dancers who watch each other. In an orchestration, a central coordinator tells each service what to do and in which order, like a conductor. Choreography lowers coupling but makes the flow hard to see and debug. Orchestration keeps the flow visible, at the cost of one component that knows everyone.
The dual-write problem and the outbox
Suppose a program saves an order in the database and then publishes OrderPlaced to the broker. These are two systems and two separate steps, so they are not atomic: the program can crash between them, leaving an order that was saved but never announced. The transactional outbox pattern fixes it. The event is saved in a table of the same database, in the same transaction as the change of state. A relay reads that table and publishes to the broker. Since the relay may publish the same row twice, delivery is at-least-once, and consumers must be idempotent.
one transaction later, in the background
+--------------------+ +-------+ +--------+
| INSERT order | | relay | ----> | broker |
| INSERT outbox row | -------> | reads | +--------+
+--------------------+ | table |
+-------+CQRS
CQRS (Command Query Responsibility Segregation) separates the model that changes state (commands) from the model that reads it (queries). That is all the pattern requires. Event sourcing, separate databases, messaging and microservices are extra choices that raise the gain in some cases and the cost in all of them. In a simple CRUD (create, read, update, delete), where reads and writes have the same shape, the separation is complexity with no return.
If the read model is updated asynchronously from events, it is stale (out of date) for a short time after each write. A user renames a project, the command succeeds, and the list loaded right after still shows the old name. Seconds later it shows the new one. This is the read-your-own-writes problem. Common ways out: show the value the command itself returned, send the next read to the write model, or wait until the projection reaches the written version.
↑ Back to the contentsDomain-driven design basics
Imagine a doctor and a programmer who must build a hospital system. If the doctor says "patient file" and the programmer's code says Record, they will misunderstand each other again and again. Domain-driven design (DDD), by Eric Evans, is a way of building software around a deep understanding of the domain, the area of business the software is about.
The ubiquitous language is the vocabulary that developers and domain experts build together and use everywhere: meetings, documents, tests and code. If the expert says "policy" and the code says Contract, every talk needs a translation, and misunderstandings sneak in. The language holds inside one bounded context.
Strategic design: the big map
A bounded context is the boundary inside which a model and its words have one single meaning. In an online shop, sales may use "product" for an item with a price and description, and shipping may use "product" for a package with weight and size. Forcing one model for the whole company produces bloated classes and confusing words. DDD accepts several models, each consistent in its own context, and makes the links between them explicit.
The context map shows how contexts relate and who has power over whom. When you depend on a context that you cannot influence and whose model does not fit yours, such as a legacy billing system, you build an anti-corruption layer. It translates between the two models at the border. It costs work to write and maintain, but your new model does not inherit the old problems.
Strategic design also splits the domain into subdomains and decides where to spend effort. A logistics startup that wins by its route optimisation should put its best people and most careful modelling there.
| Subdomain | What it is | What to do |
|---|---|---|
| Core domain | What makes the company different (route optimisation) | Best people, careful modelling |
| Supporting | Needed, but specific to the company and not a differentiator | Simple solutions |
| Generic | Works the same in any company (authentication, invoices) | Buy or reuse |
Tactical design: the pieces inside one context
An entity is defined by a continuous identity, not by the values of its attributes. A Customer can change address and e-mail over the years and is still the same customer, so two entities are compared by their identifier. A value object is defined only by its values: it has no identifier, is immutable (never changes after being built) and is compared field by field. Money (an amount and a currency) is the classic example. Operations on it return a new object, which avoids side effects when one instance is shared.
An aggregate is a group of objects treated as one unit of consistency, with a root entity as the only entry point. For an Order with its OrderItem objects, the root is Order. Outside objects hold a reference only to the root, preferably by identifier. Each transaction changes one aggregate, which keeps the rules simple to enforce and the transactions short.
Aggregate Order
+---------------------------------+
| Order (root) |
| +-- OrderItem |
| +-- OrderItem |
| +-- ShippingAddress |
+---------------------------------+
^
| only the root is referenced from outside
Customer (another aggregate, by id)The repository gives the domain the illusion of an in-memory collection of aggregates: add, find by identity, remove. There is one repository per aggregate root. The inner objects are loaded and saved with the root, and how many tables are behind it is an implementation detail. If a model has the aggregates Order and Customer, it has two repositories, even if the data uses four tables.
A domain service houses a business operation that is not the natural job of any entity or value object, usually because it involves several of them, like a transfer fee that depends on two accounts. It keeps no state and speaks the ubiquitous language. The application service sits one layer above: it coordinates the use case, the transaction and the repositories, with no business rule of its own.
A domain event records something relevant to the business that has already happened: order placed, payment confirmed. Because it describes the past, it is immutable and has a past-tense name. Other parts can react to it without the emitter knowing them, which lowers coupling between aggregates and contexts.
↑ Back to the contentsTrade-offs and architecture decision records
When you pack a school bag, a big bag holds more but is heavier. There is no perfect bag. In the same way, a well-known saying in architecture is that there are no best practices, only trade-offs. Every decision gives something up: more flexibility usually costs more indirection, and more performance usually costs ease of maintenance. A solution is not good or bad in itself, it is suitable or not for a set of requirements and limits. The architect's job is to make explicit what is gained and what is lost.
Keep options open
Robert Martin advises that a good architecture lets you defer decisions about details, such as the database or the framework, because the later you decide, the more you know. The last responsible moment is the point after which not deciding starts to cost more than deciding. Deferring does not mean doing nothing: it means building so that the detail can be chosen and replaced later. If the team does not yet know how many reads and writes the system will have, hide the database behind a port and choose when the facts arrive.
Two-way doors and one-way doors
Some decisions are like a door you can walk through and come back: a two-way door. Choosing a date formatting library, hidden behind a function of your own, is one. A mistake is cheap, so decide fast and learn by trying. Other decisions are a one-way door: hard to reverse. The format of the identifiers in a public API used by thousands of clients is one. These deserve analysis, a prototype and a written record. What defines the type is the cost of going back, and good isolation turns many decisions into two-way doors.
| Feature | Two-way door | One-way door |
|---|---|---|
| Cost of a mistake | Small | Large |
| How to decide | Quickly, then learn | Analyse, prototype, write an ADR |
| Example | A formatting library behind a function | The identifier format of a public API |
Architecture decision records
Years later, nobody remembers why the system is the way it is. An architecture decision record (ADR) is a short document that records one architectural decision and the reason for it. Michael Nygard's format has a title, a status (proposed, accepted, superseded), a context, the decision and its consequences.
# ADR 7: Use PostgreSQL in the order service
Status: accepted
Context: orders need transactions; we considered MongoDB.
Decision: we will use PostgreSQL.
Consequences: strong consistency (good); harder to scale
writes horizontally (accepted downside).A useful ADR shows the exchange: which options were considered, why the others were rejected and which negative consequences the team accepted. A record that lists only advantages is advertising, not documentation. The accepted downsides let someone later notice that the context changed and the decision should be revisited.
ADRs are append-only, like a diary. When a decision changes, for example moving from PostgreSQL to another database, you do not edit or delete the old record. You write a new ADR with the new context, and the old one gets the status superseded, with a reference to the new one. The history then shows what was known at each moment.
Not every decision needs an ADR. Write one for structurally important decisions that are hard to reverse: the architectural style, the boundaries between modules or services, the persistence model, the protocols between parts. Local decisions that are cheap to undo are already recorded by the code and the commit history. Recording everything buries the decisions that matter.
Overengineering
Layers, ports and dependency inversion are an exchange: they add indirection (extra steps between two things) to make future changes cheaper and to protect the business rules. A script that one person runs once a month, with one implementation of each interface and no plan for another, pays the cost and never gets the benefit. Using every element of clean architecture without need is overengineering.
Watch the video: Visualising software architecture with the C4 model (Simon Brown)↑ Back to the contentsHow all the ideas connect
All these styles answer the same question: how do we keep the rules that matter safe from details that change? Layers cut the program by level. The hexagon and the clean architecture go further and turn the dependencies toward the rules. Microservices apply the same thinking to whole deployments, and events let parts talk without knowing each other. DDD tells you where the boundaries should be, and ADRs remember why you chose them.
| Idea | In one line | Costs you |
|---|---|---|
| Layers | Each layer uses only the one below | Forwarding methods, coupling to the lower layers |
| Hexagonal | Application declares ports, adapters plug in | More interfaces and files |
| Clean architecture | Source dependencies point only inward | Indirection, extra translation of data |
| Modular monolith | One deployment, strong internal boundaries | Discipline to keep the boundaries |
| Microservices | Independent deploy and own data | Network failures, eventual consistency |
| Events and CQRS | React to facts, separate reads from writes | Duplicates, stale reads, harder debugging |
| DDD | Model the business with a shared language | Time spent with domain experts |
Common mistakes
- Believing that the folder names or the number of circles make an architecture clean. The dependency rule does.
- Confusing the order of calls at run time with the direction of imports.
- Splitting into services before understanding the domain, and ending with a distributed monolith.
- Putting status codes, SQL or framework types inside a use case or an entity.
- Using "fast" or "secure" as a requirement without a measurable scenario.
- Using events without making consumers idempotent.
- Writing an ADR that hides the downsides, or editing an old ADR instead of superseding it.