App architecture · advanced

Clean Architecture in production: the send-money domain

One real feature, Ledger's send money, built layer by layer. Entities that refuse invalid data, a use case that declares the protocols it needs, server JSON mapped into domain types at one border, errors translated between layers, and Swift packages that turn the dependency rule into a build error.

17 min read16 min practice0/3 exercises5 recall cards

By the end you will be able to

  • Write entities whose initialisers make invalid business states impossible to build
  • Build a use case that declares its own ports and holds the business rules in one place
  • Map wire DTOs into domain types at one border, and keep money out of Double
  • Translate errors at each layer boundary, so one layer's vocabulary never leaks into the next
  • Turn the dependency rule into a compile error with local SPM packages

Ledger is a money app, three years old, with one rule everybody in the company knows: a customer may send at most €500 per day.

That rule is written down in four separate places. The send screen's view model checks it. The Siri intent handler checks it, in its own copy of the code. The server checks it, using a slightly different definition of "day". And the widget, shipped last quarter by a different team, does not check it at all.

Here is how those four copies become a customer complaint. Follow the sequence:

  1. A customer opens the widget and sends €800.
  2. The widget has no limit check of its own, so the request goes straight to the server.
  3. The server applies its limit and rejects the transfer.
  4. The widget shows its success screen anyway. It was written to assume that a request which left the device went through.
  5. The customer believes €800 has been sent. Nothing has been sent.

Nobody on any of those four teams wrote a bad line of code. The problem is that the app never decided where business rules live, so each team decided privately, and one of them decided "nowhere".

One word before we start, because the rest of the lesson leans on it. The core is the part of the app that holds business rules and nothing else: no screens, no networking code, no database code. In Clean Architecture, the €500 rule belongs in the core, written once.

D1-03 gave you Clean Architecture as a diagram of rings. This lesson builds one real slice of it, Ledger's send-money feature, layer by layer. First the entities. Then the use case and the protocols it needs. Then the code that meets the server's messy JSON, and the errors that travel between layers. Last, the Swift packages that make the compiler enforce all of it.

Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

Ledger appears to the user on three surfaces: the send screen, the widget and the Siri intent. (A surface is any place the app shows itself.) The €500 daily limit must hold on all three. Where does the rule live?

Entities: types that refuse to be wrong

An entity is a business type: something the business would still talk about if the app did not exist. A payment. An amount of money. Entities are the innermost layer of the core, and in Swift they are plain structs. What earns them a layer of their own is that they refuse to hold nonsense.

Two rules from earlier lessons meet here.

The first is that money is stored as whole numbers, never Double. S1-01 showed the penny disappear: Int(19.99 * 100) is 1998, not 1999.

The second is about invariants. An invariant is something that must be true of a value for its whole life, such as "the amount is greater than zero". You enforce an invariant in the initialiser, so a value that breaks it can never be built in the first place. S2-01 practised validating in an initialiser, and S2-03 gives the general idea its usual name: making illegal states unrepresentable.

Loading runnable Swift…

The throwing initialiser is the whole idea. Payment has no other initialiser, so there is no way to hold an invalid Payment anywhere in the app.

That has a consequence worth stating plainly. Any function that receives a Payment already knows the amount is positive and the memo fits. It does not check again, and the codebase never fills up with defensive if amount > 0 lines written by people who were not sure. Now suppose product changes the rule to "memos may be 280 characters". You edit one initialiser. The screen, the widget and the Siri intent all get the new rule in the same build.

The use case: one action, and the ports it needs

Entities know what a valid payment looks like. They do not know how much the customer has already sent today, what the balance is, or how to record a transfer. All three of those live outside the core: on a server, or in a database on the device.

A use case is one type for one thing a user can do. SendMoney is a use case. It holds the policy — the business rules that decide whether the action is allowed — and it reaches the outside world only through protocols that it declares itself.

Those protocols have a name.

A port is a protocol declared by the core, describing a service the core needs, written in the core's own words. AccountReading below is a port. It says "tell me the balance, and tell me today's total". It says nothing about HTTP, JSON, SQL or Core Data, because the core does not care which of those is used.

The type that conforms to a port is an adapter. It lives outside the core and translates between the port's words and some real technology. The shipping app's adapter would be built on URLSession. FakeBank below is an adapter too, built from two variables and an array. It is a fake: a small stand-in type you write yourself, so that tests and examples can run without the real thing. The pair of names, port and adapter, comes from the ports-and-adapters style of architecture, which Alistair Cockburn also named hexagonal architecture.

One warning about that word, because you have met it before. D1-03 listed interface adapters as one of Clean Architecture's rings. That is Robert Martin's name for the ring holding presenters, views and controllers, which in an iOS codebase means the view models. It is a different use of the same word. In this lesson, an adapter is specifically the type that conforms to a port.

A third term goes with them: infrastructure means every piece of code that talks to something outside your program — the network, the disk, a database, the keychain, the system clock. Adapters are infrastructure.

Now the rule that arranges all of it. The dependency rule says that every import points inward: the screens may know about the core, and the adapters may know about the core, but the core knows about nobody. That is why SendMoney can declare AccountReading and never mention FakeBank.

Loading runnable Swift…

Follow the arithmetic, because the third attempt is the interesting one. FakeBank starts with €420 already sent today, and the limit is €500.

  1. Flowers, €50. Today's total would become €470, which is under €500, so the transfer is recorded.
  2. Concert tickets, €90. That would make €560. SendMoney throws before it calls record, so no money moves at all.
  3. Coffee card, €30. That lands on exactly €500. The rule is written with <=, so it is allowed.

The third attempt is why a limit belongs in one type. "Is exactly €500 allowed?" now has a single answer, and the printed output is the proof of what that answer is. Four hand-written copies of the rule would have four chances to get that one comparison wrong, and nobody would notice until a customer at exactly €500 was refused.

Notice what running this scenario needed: no screen, no network, no simulator. That is the same discipline as D1-04's ScriptedServer, one layer further in.

Send money — the layer walkSendMoneyView · ViewModelfeature layer — imports the coreSendMoney.execute()owns AccountReading · PaymentRecordingMoney · Payment · limitspure Swift — zero importsLedgerDatanetwork · DBconformsarrows are imports: they point down, and never out of the core
The slice so far. The screen calls the use case, the use case applies its rules and reaches out through its two ports, and LedgerData sits outside and conforms to those ports.

Two words worth having ready for an interview.

SendMoney is a command: it changes something in the world and returns nothing. It either succeeds quietly or throws. A query is the other shape: it returns a value and changes nothing. "Fetch the feed" is a query.

Keeping the two shapes apart is called command–query separation, and it is why asserting on FakeBank was easy. You test a command by asking what it recorded. You test a query by looking at what it returned. A method that does both forces every test to check both at once.

Check yourself

Product adds: "payments over €1,000 require Face ID confirmation." Where does each piece go?

The border checkpoint: raw JSON in, domain types out

Now the messy part, which is the network.

Two words first. The wire means the data exactly as it arrives from the server, before your app has cleaned it up. The border is the one place in your code where wire data is turned into domain types — the entities and other core types this lesson has been building. Everything on the app's side of that border may then assume the data is already good.

Ledger's /transactions endpoint returns what backends return. Field names are snake_case. Half the fields are optional, because the server team would not promise otherwise. And the amount arrives as a string, "12.34", rather than as a JSON number. S4-04 explains why: a JSON number can be turned into a Double by any tool sitting between the bank's records and your app, and Double cannot hold 19.99 exactly.

None of that mess is allowed past the border. The pattern has two parts.

  1. A DTO — short for data transfer object — is a struct shaped exactly like the wire, optionals and all. TransactionDTO below is one. Its only job is to be decodable.
  2. A mapping function takes a DTO and either returns a domain type with no optionals left in it, or returns nil because the data was not good enough to use.

The pattern's older name is the anti-corruption layer, from Eric Evans' book Domain-Driven Design: a layer whose whole purpose is to stop another system's model from leaking into yours.

Loading runnable Swift…

Two details in that code are worth stopping on.

The first is that minorUnits(from:) never touches Double. It splits the string in two and does integer arithmetic on each half. The minus sign is read from the raw text rather than taken from the parsed number, because Int("-0") is plain 0 and the sign would be lost.

The second is that the loop counts the rows it rejects instead of ignoring them. That matters more than it looks. Picture the mapper silently dropping one row of a bank statement: the customer opens the app, sees a list with a transaction missing, and reports that money has disappeared from their history. A quietly shorter list is worse than a visible error, because nothing tells anyone it went wrong. Real mappers do one of two things with rejected rows. They fail the whole refresh, or they show the list together with a line saying "2 items couldn't be shown". Neither one drops rows in silence.

Now the payoff, which arrives the day the backend changes. Suppose the server team replaces the amount string with an amount_minor integer.

  • With a border: you rename one property in TransactionDTO, change its type, and adjust two lines in mapped(_:). Every edit is in one file. Nothing else in the app ever learns that the wire changed.
  • Without a border: every type that decoded the server's JSON directly has the old field name and the old type written into it, and so does every screen reading those types. In a three-year-old app that is dozens of files, owned by several teams, all of which have to change in the same release.

That is the entire argument for the border. It turns a change that spreads through the app into a change that stops in one file.

Errors cross borders by translation

Errors have the same problem as data, and they get the same solution. Each layer has its own words for what went wrong, and the layer above translates them. Nothing is passed upward untouched.

Ledger has three of these vocabularies:

  • The transport layer knows about timeouts and HTTP status codes. That is TransportError below.
  • The core knows about business meanings: the service is unavailable, the session has expired. That is DomainError below.
  • The view model knows about sentences a customer can read.

The type that performs the first translation is the repository: the adapter that fetches and stores data on the core's behalf. It catches transport errors and throws domain errors. It is the only place in the whole app where an HTTP status code means anything.

Errors cross by translationTransportError.timeout · .http(451)catch → throwDomainError.serviceUnavailablecatch → mapStringuser wordsthe leak: a transport error surfacing in a vieweach layer speaks its own vocabulary — the caller translates at the border
Timeouts and status codes get translated into domain meaning, then into human words. Raw transport errors never reach a view.
Loading runnable Swift…

Why go to this trouble? Because you can find the alternative in most old codebases by searching for one string: error.localizedDescription. Put that straight into an alert and a customer of a banking app reads this: "The operation couldn't be completed. (NSURLErrorDomain error -1001.)"

The second failure is quieter and more expensive. A view that switches on URLError codes now depends on the networking framework, so the networking layer can no longer be replaced without editing views. The view now depends on a piece of infrastructure rather than on the core, which is precisely what the dependency rule forbids.

Translation also collects every judgement call in one place. "A 401 means the session expired" is decided once, inside fetchBalance. "Your money is safe — try again" is a sentence a fintech writer chose with care, and it lives in exactly one function. The alternative is fifty screens each inventing their own wording for a timeout.

D2-01 builds the transport half of this table: the APIError that a URLSession client throws. This lesson is where those errors are given business meaning.

Packages: the dependency rule as a compile error

Everything so far depends on people remembering the dependency rule. That holds until the week before a release, when somebody needs a date formatted and SwiftUI is right there.

So state the rule in the one form nobody can talk their way past: make breaking it a build error. Every arrow in the diagram below is an import statement, and local Swift packages let you declare which imports are legal. S4-03 covers how packages work; here is the part that matters for architecture.

Layers as packages — imports are the arrowsLedgerAppcomposition rootLedgerFeaturesviews · view modelsLedgerDataDTOs · repositoriesLedgerCoreentities · use cases · portsLedgerCore imports nothing — reversing any arrow is now a compile error
Four packages. Every arrow is an import. No arrow leaves LedgerCore, because LedgerCore imports nothing.
// Package.swift (LedgerCore) — the whole point is what ISN'T here
let package = Package(
    name: "LedgerCore",
    products: [.library(name: "LedgerCore", targets: ["LedgerCore"])],
    targets: [
        .target(name: "LedgerCore", dependencies: []),   // ← zero
        .testTarget(name: "LedgerCoreTests", dependencies: ["LedgerCore"]),
    ]
)

dependencies: [] is the whole trick. A teammate in a hurry adds import SwiftUI to SendMoney.swift, and the build fails on the spot. The rule is now enforced by the compiler, so it never has to be argued about in a code review.

Three more things follow from that graph, and they are the ones a team notices day to day:

  • LedgerCore's tests build without compiling a single view, so they finish in seconds.
  • A SwiftUI preview of a screen in LedgerFeatures does not compile the networking code, because LedgerFeatures does not import LedgerData.
  • The widget target — the separate build product Xcode produces for the widget — links LedgerCore and LedgerData only. It cannot reach screen code, and that fact needs no meeting to establish.

The everyday version of all this: the import lines at the top of a file now tell you which layer the file belongs to.

Somewhere, though, the real types have to be created and handed to the code that needs them. That happens in one file at the very top of the app, called the composition root. It is the only place allowed to import every layer and to name concrete types such as LiveBank. D1-01 taught the habit of passing dependencies in through init; the composition root is where that chain of hand-overs starts.

@main
struct LedgerApp: App {
    // The one place that knows concrete types.
    private let dependencies = AppDependencies.live()   // previews build a fake one instead

    var body: some Scene {
        WindowGroup {
            FeedRoot(repository: dependencies.transactions,
                     sendMoney: dependencies.sendMoney)
        }
    }
}

When each layer is not worth it

Loading exercise…
Loading exercise…
Loading exercise…
Design itSaved on this device. Never graded.

Product wants scheduled payments: "send €200 to my landlord on the 1st of every month." Write the file plan in prose.

There are five new pieces: the schedule entity, the rule that works out the next due date, the code that saves schedules, the on/off switch in the UI, and the background execution. Say which package each one goes in, and say which existing types have to change. Justify every placement with the dependency rule. Then name the one piece of this feature that genuinely cannot be done on the device.

Checkpoint

You can now:

  • Write entities whose initialisers make invalid business states impossible to build
  • Build a command use case that declares its own ports, and prove where a rule's boundary sits using a fake adapter
  • Run the border checkpoint: map a DTO into a domain type with no Double and no silently dropped rows
  • Translate errors at each layer, so no layer's vocabulary leaks into the one above it
  • Explain how local SPM packages turn the dependency rule into a build error, and name the trigger for each of the three steps

Next up: the capstone. All of Ledger on one page: the module map, one request traced end to end, sign-in and token refresh, offline writes, and where UIKit still fits.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.