Skip to content
andydataguy
Wiki Corpus

Systems Design. A web app and command-line tool should call the same business operation.

AI & TECHNICAL · Reading guide~9 min read · 2,104 words

A web request and a command-line tool can ask a backend to make the same change. The rules for allowing that change should have one implementation. Systems design starts with deciding which behavior those callers share and which work belongs to each caller.

Take a hypothetical billing change. A web application lets a user change a billing preference. An internal command-line tool needs to make that change too. The web request carries its input in an HTTP body, while the command-line tool receives arguments. Both callers still need the same answer about whether the change is allowed.

Putting that decision inside the HTTP route leaves the command-line developer with a choice. Call through HTTP, extract the operation, or copy the rule. Copying creates another place to update when the rule changes, and another place where a missed update can produce different behavior.

The useful boundary separates request translation, business decisions and the reads or writes needed to carry them out. Ports and adapters, a functional core, and component-based data each address part of that separation. They can be adopted independently.

SAME LOGIC, FIVE SURFACES HTTP MCP CLI BOT WEB ONE CORE
Business logic lives in the band marked ONE CORE and nowhere else. Each surface above that band is a thin adapter that translates a request and hands it down, so another interface can call the same business rules.

Adapters translate requests into a shared operation

An operation names a business action and defines the data it accepts and returns. A billing-change operation might accept a user identifier, the requested preference and the relevant state. Its output describes either an allowed change or a reason to reject it. The contract gives every caller the same meaning for those inputs and outcomes.

Alistair Cockburn's hexagonal architecture, also called ports and adapters, separates application behavior from the technology used to reach it. A port describes an interaction the application supports or needs. An adapter connects that interaction to a particular technology. An incoming HTTP adapter and an outgoing database adapter have different jobs, even though both sit at an application boundary.

The HTTP adapter parses a request, validates its shape, calls the operation and translates the result into a response. A command-line adapter does equivalent work with arguments and an exit result. MCP, the Model Context Protocol, provides another way to expose an operation to an agent. A direct test can call the operation without HTTP, command-line parsing or MCP.

These callers should agree on the business rule. They may still need different authentication mechanisms, error formats and ways to present a result. A new adapter also has to preserve authorization. Accepting a correctly shaped user identifier says nothing about whether the caller may change that user's billing settings.

A thin adapter delegates the decision while retaining the work specific to its interface.

Hexagonal boundaries can isolate business behavior that still performs I/O through a port. Making the decision itself a pure function is a further choice. That choice determines what the operation needs to receive before it can answer.

A central hexagon labelled CORE holds typed operation cards. Five thin adapters ring it, labelled HTTP, CLI, MCP, Agent and Test, each drawn as a slim panel about thirty lines of code deep. Arrows run inward from every adapter into the core and back out as typed responses.
Five thin adapters (HTTP, CLI, MCP, agent and test) ring one core, and every request runs inward to the same typed operations and back out as a typed response.

A functional core decides what the shell carries out

Gary Bernhardt's functional core, imperative shell pattern separates decisions from side effects. A pure core function calculates an output from explicit inputs. The shell reads a database, calls an external API or writes a file. It supplies the information the core needs and performs the effects the core's output describes.

The decision uses explicit state

The hypothetical billing operation can receive the user's current billing settings and the proposed change as values. Its decision can then be tested without a database connection. The shell still has to fetch the right user's state, apply the allowed change and report a failed write. A wrong decision and a failed write can then be investigated separately.

A pure decision also has a limit. It answers for the state it received. Another request may change that state before the shell commits a write. The persistence design still needs to handle that possibility. Purity alone can't make a read followed by a write safe.

Tests cover decisions and effects separately

My Python work uses Pydantic data models to make the data flowing between functions explicit. Those models give me one place to refine the objects passed between integrations, databases and agents. The accompanying practice includes FastAPI interfaces, LogFire for observing operations and Hypothesis for property-based testing. Those tools support different jobs. Their presence in a repository doesn't establish that the jobs are done well.

Property tests can generate valid inputs and check an invariant, such as a rejected billing change producing no instruction to write new settings. The author still has to choose a useful invariant. A passing property test says nothing about behavior it never checks.

Integration tests exercise the shell's actual reads, writes and external calls. The core and shell therefore need different evidence. A well-tested decision can sit behind a broken database mapping, and a successful database call can persist the wrong decision. The boundary helps locate both problems without treating either test as a substitute for the other.

Components hold the data a system needs

Separating decisions from I/O leaves a data-modeling question. What information does each decision need, and how should that information be grouped? Entity-Component-System, or ECS, offers a compositional answer borrowed from game engines.

Entities and components have different jobs

A typed identifier distinguishes one entity from another. A component holds a typed record associated with that identity. A system operates on entities that have the components it requires. A user can have profile, authentication, billing and preference components without one User class owning every action related to that person.

The billing-change system needs the relevant billing and permission information. It doesn't need to carry the user's display-profile behavior through the operation. A later capability can introduce another component and a system that uses it. Each capability can use the same entity identifier without requiring a new subclass.

Composition can reduce the number of unrelated changes concentrated in one entity class. It also creates responsibilities. The team must define which component combinations are valid, what happens when a component is absent, and how systems coordinate changes to shared data. Adding a component can leave existing behavior alone. Changing a component's meaning may require changes to every system that consumes it.

Storage still needs a mapping

Pydantic V2 models can define component shapes in Python. A persistence layer can store entities and their components, for example in a Convex table organized by entity type. Keeping the decision function pure means passing selected component values into it and returning proposed changes. The persistence boundary must map those values to storage and apply the changes.

A Python object still needs a defined conversion to a stored record. Validation, serialization and database constraints still need an explicit mapping. ECS describes how behavior and data compose. A database choice describes how that data is stored and queried. Either choice can change without requiring the team to adopt the other wholesale.

On the left, a heavy god-class block labelled User carrying sixty stacked method bars. An arrow points right to an ECS layout: a bare User entity ID with six typed component cards attached to it for profile, auth, billing, preferences, sessions and audit. Below the entity, system functions operate on subsets of those components.
In ECS decomposition, an entity ID, typed components, and system functions over component subsets replace the god-class. A new capability can use a new component and a system function.

Extract the responsibility that makes a change difficult

An existing backend rarely needs every architectural choice replaced at once. Start with a required change and identify the dependency that prevents it. The same outward symptom, such as difficulty adding a command-line caller, can come from different kinds of coupling.

A handler can own the business rule by accident

An HTTP handler may authenticate a caller, parse input, decide eligibility, read and update records, then format a response. A second caller needs only part of that sequence, but the route gives it no separate operation to call.

Extract the business decision with explicit inputs and outputs. Keep request parsing and response formatting in the adapter. Preserve the permission checks and write conditions while moving them to the responsibility that owns them. A shorter handler that quietly drops either check is a broken extraction.

A storage model can become the application contract

An object-relational mapper, or ORM, connects application objects to relational storage. It can make ordinary database work convenient. Coupling grows when its model also defines the business operation, the object sent to every caller and the rules for changing it.

A database refactor then reaches callers that needed only a business result. An application-owned data model can separate those concerns. In Python, Pydantic can define that intermediate representation, with explicit conversions at the storage boundary. A content compiler uses an intermediate representation to connect different inputs and outputs through a shared shape.

The mapping still needs tests, and the queries still need to fetch the right data. A typed contract doesn't repair an inefficient query or make database-specific behavior portable. Keep the ORM where it helps, and move a business rule out of its model when that dependency obstructs a required change.

An entity class can collect unrelated behavior

A User class that owns profile display, authentication, billing and notification behavior gives unrelated features a shared place to change. Component-based records and separate system functions can let those features evolve with fewer edits to that class.

Start with the behavior that needs independence. Define the data it uses and who may change it. Extracting every field into a component before establishing those responsibilities can multiply files without improving the next operation. A small function boundary may solve the problem before a full ECS design earns its cost.

A small tool may need only a function boundary

A short-lived internal tool with one caller may need a validated input, a function that makes a decision and a direct way to save its result. Adding a port for a database the team has no reason to replace, or introducing a component system for data that never varies, creates work the tool may never repay.

The operation should expose what it does well enough for someone to change it safely. A small design can keep a rule in a plain function without adding an adapter framework, a repository hierarchy or a new service. Security checks and protection against bad writes remain part of the job at every size.

The investment becomes easier to justify when actual changes repeatedly cross the same boundary. A web route and an agent tool need the same rule. A storage migration requires edits throughout the application. Several features keep modifying an entity class for unrelated reasons. Each observation identifies a cost a boundary could reduce.

Team size and an expected lifespan can inform that judgment, but neither establishes the return. A small team may already support several interfaces. A larger team may maintain a stable application whose existing structure serves it well. A funded roadmap also remains a plan. The design should account for the change the team has evidence it needs.

Uncertainty favors an extraction that's useful on its own. Pull the billing decision into a function, give its inputs and result a clear shape, and let the existing handler call it. A later caller can reuse that operation. ECS can wait until the data needs composition, and a database port can wait until a storage dependency causes a specific problem. Each additional boundary should have a job the team can name.

Inspect an operation before approving a redesign

A backend team can demonstrate its boundaries with an operation the product already supports. Use the billing change from the opening as the question to follow. A diagram can name a core and adapters. The operation shows whether the rule is shared and what happens when it fails.

Ask the team to trace the change through its actual implementation:

  1. Identify the input contract and the place where the caller's permission is checked.
  2. Find the decision that allows or rejects the change. Establish whether another interface calls that decision or repeats it.
  3. Follow the allowed result to the database write, including the handling of a failed write or changed state.
  4. Read the tests for rejected and allowed changes. Separate evidence about the decision from evidence about the persistence and transport boundaries.

Each answer should lead to code and an observable result. A typed schema can describe the input while leaving an important permission rule out. A property test can exercise an invariant while missing the behavior the business cares about. Check that the input contract, permission check and business rule lead to the intended saved result.

Database replacement deserves the same scrutiny. An operation isolated from storage details may survive a migration, while its adapter, queries and transaction assumptions still need work. Reusing the decision is a concrete benefit. Predicting that the whole migration will be cheap requires evidence about the rest of the system.

The web application and command-line tool should call the same billing decision, preserve the same restrictions and report what happened to the requested change. Approve the smallest extraction that demonstrates that behavior.

// RELATED
For the architectural pattern in long form, read Unified Architecture. For the production-hygiene companion, read The Observability Manifesto. For the operational-systems sister piece on workflow shape, read State Machine Everything.

Keep reading