SD System Design In Depth
0%
lesson system-designarchitecturemaintainability

Logical system design

Module 1.1: Foundations & System Invariants Updated Sep 17, 2026
📄 Cheat Sheet
Moving IO to the edges of your app: Functional Core, Imperative Shell - Scott Wlaschin
NDC Conferences Moving IO to the edges of your app: Functional Core, Imperative Shell - Scott Wlaschin Watch on YouTube ↗

Scott Wlaschin clearly shows how to keep decision logic separate from HTTP and database I/O so rules can be tested without infrastructure, which is exactly the boundary this unit draws between adapters and link operations.

0:00 / --:--

Logical system design

Logical system design decides where application rules live and how modules work together. It helps answer a practical question: when a product rule or external provider changes, which code should need to change with it?

We will separate the shortener's HTTP handling, link rules, and storage work, then check whether those boundaries make a change easier to test.

Give each responsibility an owner

A route handler can parse a request, run SQL, check whether a link is disabled, record a click, and format the redirect. For a small operation, that can be readable. Trouble starts when an import job needs the same rules, or every handler begins interpreting database rows differently.

Separate the work when those responsibilities need to vary independently:

ResponsibilityWhat it owns
HTTP adapterParse the request and translate the result into an HTTP response
Link operationApply the product's rules for creating or opening a link
Storage adapterExecute queries and map stored records into the application's result types
A boundary is the agreement between these parts. It can be an ordinary function call inside one process. Drawing separate boxes does not require separate servers or deployments.
flowchart TB accTitle: Two callers share the same link rules accDescr: HTTP handling and an import job call link operations, which use a storage adapter. These are application responsibilities, not separate machines. HTTP[HTTP handling] --> Rules[Link operations] Import[Import job] --> Rules Rules --> Storage[Storage adapter] Storage --> DB[(Database)]

The HTTP path creates one link; the import job may create many. Both must enforce the same rule that a generated code cannot replace an existing mapping. The database's uniqueness constraint enforces the collision rule atomically; the application decides whether to retry with another candidate or return an error.

Make the agreement useful to its caller

Consider resolve(code). “Returns a URL” leaves several cases unstated. The link might be absent, disabled, or temporarily impossible to look up.

ResultMeaningHTTP caller's action
FoundThe operation permits this link and supplies its destinationContinue the redirect path
MissingThe lookup succeeded, but no mapping existsReturn not found
DisabledThe mapping exists but cannot be usedRefuse the redirect
UnavailableThe operation could not establish an answerReturn a service error
Keep unavailable distinct from missing. Translating a connection failure into “no such link” gives the caller a false answer. Likewise, the storage adapter should not leak a vendor-specific exception into every route and job that uses it.

For the shortener's agreed contract, a successful redirect also waits for its request event to be stored. That sequencing belongs to the link operation. HTTP formatting does not decide whether losing a click is acceptable.

Test the rule through the boundary

To test “disabled links never redirect,” supply a small in-memory storage implementation that returns a disabled record. Exercise the link operation directly. The test should not need a browser, a live database, or knowledge of SQL syntax to check that product rule.

That test does not establish that the real database query works. Test the storage adapter against the database separately, including missing rows, uniqueness conflicts, and failures. Keeping these tests distinct helps identify whether a failure comes from a rule or its persistence implementation.

The same approach applies to a payment or notification provider: expose the operation the application needs, then translate it to the provider's API in one place. A replacement provider may have different guarantees, so an interface reduces the places to inspect; it does not make providers automatically interchangeable.

Stop before the boundary becomes ceremony

A function that forwards unchanged arguments through three layers is not necessarily protecting anything. Introduce a boundary around a repeated rule, a dependency that changes, or a decision worth testing independently.

Review a likely change: add an import job, replace a notification provider, or introduce link expiry. If each requires edits across unrelated route handlers, the rule probably has no clear owner. If one focused operation owns the change and its tests, the logical design is doing useful work.

The repository pattern develops the storage boundary further. Monoliths and microservices asks a separate question: which of these responsibilities need independent deployment?


Logical system design

Logical system design decides where application rules live and how modules work together. It helps answer a practical question: when a product rule or external provider changes, which code should need to change with it?

We will separate the shortener's HTTP handling, link rules, and storage work, then check whether those boundaries make a change easier to test.

Give each responsibility an owner

A route handler can parse a request, run SQL, check whether a link is disabled, record a click, and format the redirect. For a small operation, that can be readable. Trouble starts when an import job needs the same rules, or every handler begins interpreting database rows differently.

Separate the work when those responsibilities need to vary independently:

ResponsibilityWhat it owns
HTTP adapterParse the request and translate the result into an HTTP response
Link operationApply the product's rules for creating or opening a link
Storage adapterExecute queries and map stored records into the application's result types

A boundary is the agreement between these parts. It can be an ordinary function call inside one process. Drawing separate boxes does not require separate servers or deployments.

mermaid
flowchart TB
  accTitle: Two callers share the same link rules
  accDescr: HTTP handling and an import job call link operations, which use a storage adapter. These are application responsibilities, not separate machines.
  HTTP[HTTP handling] --> Rules[Link operations]
  Import[Import job] --> Rules
  Rules --> Storage[Storage adapter]
  Storage --> DB[(Database)]

The HTTP path creates one link; the import job may create many. Both must enforce the same rule that a generated code cannot replace an existing mapping. The database's uniqueness constraint enforces the collision rule atomically; the application decides whether to retry with another candidate or return an error.

Make the agreement useful to its caller

Consider resolve(code). “Returns a URL” leaves several cases unstated. The link might be absent, disabled, or temporarily impossible to look up.

ResultMeaningHTTP caller's action
FoundThe operation permits this link and supplies its destinationContinue the redirect path
MissingThe lookup succeeded, but no mapping existsReturn not found
DisabledThe mapping exists but cannot be usedRefuse the redirect
UnavailableThe operation could not establish an answerReturn a service error

Keep unavailable distinct from missing. Translating a connection failure into “no such link” gives the caller a false answer. Likewise, the storage adapter should not leak a vendor-specific exception into every route and job that uses it.

For the shortener's agreed contract, a successful redirect also waits for its request event to be stored. That sequencing belongs to the link operation. HTTP formatting does not decide whether losing a click is acceptable.

Test the rule through the boundary

To test “disabled links never redirect,” supply a small in-memory storage implementation that returns a disabled record. Exercise the link operation directly. The test should not need a browser, a live database, or knowledge of SQL syntax to check that product rule.

That test does not establish that the real database query works. Test the storage adapter against the database separately, including missing rows, uniqueness conflicts, and failures. Keeping these tests distinct helps identify whether a failure comes from a rule or its persistence implementation.

The same approach applies to a payment or notification provider: expose the operation the application needs, then translate it to the provider's API in one place. A replacement provider may have different guarantees, so an interface reduces the places to inspect; it does not make providers automatically interchangeable.

Stop before the boundary becomes ceremony

A function that forwards unchanged arguments through three layers is not necessarily protecting anything. Introduce a boundary around a repeated rule, a dependency that changes, or a decision worth testing independently.

Review a likely change: add an import job, replace a notification provider, or introduce link expiry. If each requires edits across unrelated route handlers, the rule probably has no clear owner. If one focused operation owns the change and its tests, the logical design is doing useful work.

The repository pattern develops the storage boundary further. Monoliths and microservices asks a separate question: which of these responsibilities need independent deployment?

Check yourself Question 1 of 3
Where sequencing belongs recall
From: Logical system design

Of the three responsibilities (HTTP adapter, link operation, storage adapter), which decision belongs to the link operation?

Key Takeaways

Separate HTTP handling, business rules, and storage into distinct responsibilities with explicit boundaries.
Drawing separate boxes does not require separate servers; boundaries can be in-process function calls.
A boundary is tested by checking whether a change in one module forces changes in others.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue