The practical guide

Bounded Software Architecture

Start at the domain concept. Keep its connected code close. Make the paths between owners explicit.

01 / Start with a change

Give the next change an obvious home

Imagine joining a team that builds software for a shipping company. Your first task sounds small: a shipment must have a different destination from its origin. You find the shipment class in one folder, validation in another, and registration logic in a third. Before changing the rule, you have to discover which place actually makes the decision.

Bounded Software Architecture starts with that everyday problem. Keep the code that describes a thing close together, give it a clear owner, and agree on how other code may use it. Someone changing a shipment should know where to begin.

The work your software deals with is its domain. For our fictional company, Meridian, that work includes shipments, vehicles, and deliveries. Keeping their related code nearby is what we mean by domain proximity.

# An illustrative starting point
models/shipment.py
validators/shipment.py
repositories/shipment.py

# Bring the shipment's definitions and rules together
shipping/shipment.py

Moving files is only the beginning. We also need to decide which rules belong together and which dependencies make sense. We will build up those decisions one example at a time.

The examples use Python 3.12 and our fictional shipping company, Meridian. We start with locations that have a code, name, and coordinates, then follow how shipment code connects to other responsibilities.

BSA concerns source organization, ownership, and imports. It can help organize a single application; a source boundary does not require a separate service, database, or team.

02 / Concepts and value objects

Describe a shipment and its locations

A shipment travels from one place to another. We could represent each place as a string, but our application needs more information: a location has a code, a name, and coordinates. Those values belong together because they describe one place.

Start with coordinates. Latitude and longitude have limits, so the object checks them when it is created. A rule that must remain true is called an invariant.

# Teaching example: shipment.py
from dataclasses import dataclass
from typing import NewType


@dataclass(frozen=True)
class Coordinates:
    latitude: float
    longitude: float

    def __post_init__(self) -> None:
        if not -90 <= self.latitude <= 90:
            raise ValueError("Latitude must be between -90 and 90.")
        if not -180 <= self.longitude <= 180:
            raise ValueError("Longitude must be between -180 and 180.")

Now give the location a code and a name. LocationCode lets a type checker distinguish a location code from an ordinary string. It does not validate the string at runtime; the location still checks that its code is not blank.

# Add to shipment.py
LocationCode = NewType("LocationCode", str)


@dataclass(frozen=True)
class Location:
    code: LocationCode
    name: str
    coordinates: Coordinates

    def __post_init__(self) -> None:
        if not self.code.strip():
            raise ValueError("A location must have a code.")
        if not self.name.strip():
            raise ValueError("A location must have a name.")

Location and Coordinates are value objects. Their values describe them, and equal field values make equal objects. The frozen dataclasses keep their fields from being reassigned. To represent a different set of values, create another object.

The shipment owns the journey’s rule

Two shipments can travel between the same locations, so a shipment needs an identifier of its own. It also checks the relationship between its origin and destination:

# Add to shipment.py
ShipmentId = NewType("ShipmentId", str)


@dataclass(frozen=True)
class Shipment:
    id: ShipmentId
    origin: Location
    destination: Location

    def __post_init__(self) -> None:
        if not self.id.strip():
            raise ValueError("A shipment must have an identifier.")
        if self.origin.code == self.destination.code:
            raise ValueError(
                "The destination must differ from the origin."
            )

Here we assume a location code uniquely identifies a place within the application. We compare codes because changing a display name should not make the same place a valid destination. The rule is about the journey, so it belongs to Shipment.

Each object checks the part it understands. Coordinates know their geographic limits, Location requires its descriptive values, and Shipment checks its endpoints. A website and a background worker can create shipments without maintaining separate copies of those rules.

For now, all three classes can live in shipment.py. A value object does not automatically need its own file or boundary. If locations later become a separate responsibility, we can give them a home of their own.

03 / Contracts and implementations

Keep shipment storage nearby

Creating a shipment is useful only if we can find it again. First, describe the storage operations the application needs:

# Add to shipment.py
from typing import Protocol


class Shipments(Protocol):
    def add(self, shipment: Shipment) -> None: ...

    def find(self, shipment_id: ShipmentId) -> Shipment | None: ...

Shipments is a repository contract. It says that callers can add a shipment and find one by its identifier. Python’s Protocol lets a type checker check that an implementation provides those operations. It does not choose or construct that implementation.

A dictionary is enough for tests and small examples. Create a fresh instance in each test to keep its data separate:

# Add to shipment.py
# Useful for tests: no database setup needed.
class InMemoryShipments:
    def __init__(self) -> None:
        self._shipments: dict[ShipmentId, Shipment] = {}

    def add(self, shipment: Shipment) -> None:
        self._shipments[shipment.id] = shipment

    def find(self, shipment_id: ShipmentId) -> Shipment | None:
        return self._shipments.get(shipment_id)

To keep shipments after the application stops, we can provide the same operations using a database. DatabaseShipments takes a connection pool as a constructor argument. The pool manages reusable database connections; the repository borrows one when it needs to read or write.

Database implementation — pseudocode. The pool and database methods depend on the library you choose.
# Pseudocode for shipment.py
class DatabaseShipments:
    def __init__(self, pool: ConnectionPool) -> None:
        self._pool = pool

    def add(self, shipment: Shipment) -> None:
        with self._pool.connection() as connection:
            with connection.transaction():
                connection.upsert(
                    "shipments", to_record(shipment), key="id"
                )

    def find(self, shipment_id: ShipmentId) -> Shipment | None:
        with self._pool.connection() as connection:
            record = connection.find_one("shipments", id=shipment_id)
        return None if record is None else from_record(record)

Here, to_record and from_record stand for conversion helpers we have left out. They save and rebuild the shipment’s identifier and both locations, including their codes, names, and coordinates. Rebuilding the objects also runs their validation. The database write replaces an existing record with the same identifier, just like the dictionary does.

# Application setup — pseudocode
pool = ConnectionPool(connection_string=settings.database_url)
shipments = DatabaseShipments(pool)

Application setup supplies the connection string and opens and closes the pool. DatabaseShipments receives that pool; it does not look up configuration itself. We will return to this setup in chapter 10.

Keep Shipment, Shipments, InMemoryShipments, and DatabaseShipments together. They describe the shipment, the storage operations available to callers, and how those operations work. Someone learning about shipment storage can begin in one place.

This gives the module cohesion: its contents have a common reason to be there. Cohesion also gives us a limit. Vehicle registration would need a different explanation and belongs elsewhere, even though vehicles carry shipments.

The dictionary currently replaces a record with the same identifier, and its contents disappear when the process ends. In chapter 9, we will make registration reject duplicate identifiers before adding them. The database example is a sketch; a working implementation would also need a schema, conversion helpers, and a way to handle concurrent writes.

04 / Boundaries and anchors

Give the concept an owner

Meridian also needs to register vehicles. We give those definitions a home in fleet/, while shipments belong to shipping/. Each has a responsibility we can explain.

Each area is a boundary. Saying “fleet owns vehicle registration” means fleet is where that behavior belongs and where we go to change it. Shipping and fleet are also business capabilities: things the company needs to do. The word capability does not require another layer of folders.

Boundaries form an ownership tree

The application sits at the root of the tree. Fleet and shipping sit directly below it as sibling boundaries: they share a parent, but each owns a different responsibility.

meridian/               # Application root
├── fleet/              # Boundary owned by the application
│   └── vehicle.py       # Fleet's vehicle concept
└── shipping/           # Boundary owned by the application
    └── shipment.py      # Shipping's shipment concept

# Other files omitted for now.

A boundary can contain smaller boundaries of its own. Each of these children has exactly one parent and owns a more specific part of that parent’s work. As we add deliveries inside shipping, for example, we extend one path through the tree: Meridian, shipping, then deliveries.

The tree describes ownership. It gives us a place for each responsibility before we decide which parts may depend on which others. The next chapter explains when a responsibility belongs below an existing boundary and when it should sit alongside it.

A folder becomes a boundary because the project gives it a responsibility and agrees on how other code may use it. Record that decision in the project’s connection map and import rules. Creating a directory alone does not establish separate ownership.

The anchor gives related code a vocabulary

Delivery code needs to identify a shipment. It should be able to use ShipmentId without understanding how registration works. BSA calls a boundary’s central domain concept its anchor: here, the shipment and its related values, rules, and storage contract.

Anchors also connect domain code across boundaries. When another boundary needs a shipment’s identity or storage contract, it uses the public vocabulary exposed by shipping’s anchor. Shipping keeps ownership of those definitions; its callers have an agreed way to use them.

The concept lives in shipment.py. In Python, the package root can export the names other code is allowed to use:

# Selected exports in shipping/__init__.py
from meridian.shipping.shipment import ShipmentId, Shipments

# The delays boundary uses shipping's anchor vocabulary
from meridian.shipping import ShipmentId

The same idea applies between sibling capabilities. Assignment code uses VehicleId and Vehicles through fleet’s anchor. That gives shipping a connection to fleet without depending directly on fleet’s internal files. The connection must still follow the direction agreed between those boundaries.

Defining the concept and exporting selected names are different jobs. The anchor is not an extra class or a mandatory anchor.py file. Keep its exports focused on its own concept; a child’s definitions can be exported from that child’s root.

Anchors provide the vocabulary for these connections. Operations and construction have their own public interfaces, which we will introduce in chapter 6.

05 / Private branches and children

Make room for another responsibility

Shipping will eventually handle deliveries, vehicle assignments, and delays. More files can help, but there are two different reasons to add a folder.

A private branch organizes implementation for an existing owner. Meridian’s shipping/_commands/ contains shipment registration code. It is still shipping’s work. Callers use the interface shipping provides instead of depending directly on these implementation files.

A child boundary, or sub-boundary, owns a distinct responsibility within a larger one. Deliveries describe how a shipment’s journey is carried out. Assignments describe which vehicles are allocated to it. Each has its own rules, but those rules make sense in the context of the work above it.

Read the boundaries as a tree

Ownership runs down the tree. Each child belongs to one parent and describes a more specific part of its work.
Shipping
└── Deliveries
    ├── Assignments
    └── Delays

Follow one branch and you get a path: shipping, then deliveries, then delays. At each step, the responsibility becomes more specific. You can understand shipping’s shipment concept without knowing how delay reporting works. To understand a delay report, you need to know which shipment it concerns.

Along that path, knowledge of shared concepts points upward. A lower concept can use the supported vocabulary of its parent or ancestors. The concepts above it do not depend on it in return. For example, delay reporting uses ShipmentId, while the shipment definition does not import delay reports.

Shipping owns deliveries, and deliveries owns assignments and delays. Each boundary takes care of its own part of the work and can give a smaller part to a child. This pattern can repeat at any depth in the tree. That is all recursive ownership means.

To find a file’s direct owner, walk up its folders until you reach the first boundary. For code inside assignments/, that owner is assignments. The surrounding deliveries and shipping boundaries do not get access to its private code just because they contain it. Likewise, assignments and delays need an explicitly allowed connection to use each other’s code; sharing a parent is not enough.

This describes the direction between domain concepts. Construction code has a different job: a parent’s factory may assemble its children through their allowed interfaces. Connections to other capabilities, such as shipping using fleet, are declared separately. We will separate those views in chapter 8.

Decide whether the capability stands on its own

A sub-boundary is still a boundary. The decision is where it belongs in the tree. A useful way to choose is to consider its callers: would another part of the application use this capability on its own, or would it usually need the proposed parent’s capability as well?

Use the relationship between capabilities to guide their placement.
How the capability is usedWhat that suggests
Other boundaries can use it without taking on the proposed parent’s work.Give it a boundary alongside that parent, under an appropriate common owner.
Callers usually need the parent’s concepts and operations as well.Make it a child boundary: it owns a distinct part of that larger capability.
It only helps implement the existing responsibility.Keep it in the owner’s private implementation.

Fleet is useful without shipping. Imagine adding vehicle maintenance: it would need vehicle definitions, but no shipment or delivery. That is a reason to place fleet alongside shipping, where either shipping or a future maintenance capability could use it independently.

Delivery tracking usually comes with a shipment to track. Callers need the shipment’s identity and journey as well as its delivery progress. That makes deliveries a natural child of shipping. Delay reporting narrows that work further, so it can sit below deliveries.

Treat this as a heuristic, not a count of imports. One outside caller does not automatically turn a child into a sibling boundary. If that caller is still working in the same shipment context, it can use the child’s public interface. Look for independent meaning and use, and reconsider the placement as the application’s needs become clearer.

See how the tree maps to files
shipping/
├── shipment.py
├── _commands/           # Private shipping implementation
└── deliveries/          # Child boundary
    ├── delivery.py
    ├── assignments/     # Child of deliveries
    │   ├── assignment.py
    │   └── _commands/   # Private assignment implementation
    └── delays/          # Child of deliveries
        └── delay_report.py

# Export and construction files omitted.

Being in a parent folder does not grant access to a child’s private files. Parent code uses the child’s supported interface. If construction needs additional access, the project must declare that permission explicitly.

Consider a registration function that keeps growing

Several helpers may make the function easier to read without creating a new responsibility. Those helpers can stay in a private branch. A child boundary becomes useful when you can explain its own vocabulary and rules. File count alone cannot make that decision.

06 / Public surfaces

Choose what other code may use

Other code needs some things from shipping: shipment identifiers, operations such as registration, and a way to obtain the objects that perform those operations. It does not need every file shipping happens to contain.

A surface is the interface a boundary agrees to make available. Its supported names might be classes, functions, or values. Its audience is the code allowed to use those names. A caller using a surface is also called its consumer.

The Meridian starter provides different surfaces for different callers.
SurfaceWhat callers use it for
__init__.pyPermitted domain code imports vocabulary such as ShipmentId and Shipments.
commands.pyEntry code imports operations. The starter exports command values and handler types here.
deps.pyEntry and construction code obtains the objects needed to perform work.
routes.pyThe HTTP application imports the capability’s router.

We will build a command and a factory shortly. For now, notice that each surface serves a purpose. A small boundary only needs the interfaces its callers actually use.

# Supported: assignment code uses fleet's public vocabulary
from meridian.fleet import VehicleId, Vehicles

# Unsupported: that caller reaches into fleet's concept file
from meridian.fleet.vehicle import VehicleId, Vehicles

Python can execute both imports. Fleet’s agreement with its callers is what makes the first path supported. Import checks enforce the restriction; underscores and __all__ cannot stop an explicit private import on their own.

Make the whole interface usable

Suppose a public function accepts a type that callers can only obtain from a private file. They would have to break your import rule just to call the function. Review its arguments and result as well as its name. The types a caller needs should also be available through supported surfaces.

07 / Imports and direction

Agree on which way dependencies go

Assigning a vehicle requires knowing that the vehicle exists. Shipping therefore uses fleet. Registering a vehicle does not require knowing anything about shipments, so fleet can remain independent of shipping.

This domain connection is formed through fleet’s anchor. Assignment code imports VehicleId and Vehicles from fleet’s public root, using the vocabulary fleet has chosen to expose.

This gives us a dependency direction: shipping → fleet. In a source diagram, an arrow means “imports from.” It shows which code relies on which other code being available.

A public surface does not give every part of the application permission to use it. Although ShipmentId is public, importing it into fleet would go against our chosen direction. Both the path and the caller have to be allowed.

Write down enough about a connection that someone else can review it. Here is the assignment connection in plain language:

Who needs it
The assignment code, owned by assignments within shipping.
Who provides it
Fleet, through its package root.
What it provides
VehicleId identifies a vehicle. Vehicles describes vehicle storage and lookup.
Who may use it
Assignment domain code is a permitted audience for that vocabulary.
Why this direction
Assignments need vehicle definitions. Vehicle definitions do not need assignment rules.

If fleet imports shipping in return, the two parts depend on each other. That creates a cycle. Moving an import inside a function may postpone a Python error, but it leaves the source relationship in place. Revisit the owner of the shared decision or put the connecting work at an appropriate common owner.

Sibling boundaries need their own declared connections too. Assignments and delays happen to share a parent, but the starter keeps them independent. Their position in the folder tree gives neither one permission to import the other.

08 / Ownership and dependencies

Read the two maps separately

A folder tree answers “where does this belong?” An import graph answers “what does this code depend on?” Both are useful, but their lines mean different things.

Ownership: each indented item belongs to the boundary above it.
Shipping
├── Shipment concept
├── Construction code (deps.py)
└── Deliveries
    ├── Delivery concept
    ├── Assignments
    └── Delays

A child may need vocabulary from its parent. Delay reports use ShipmentId. Meanwhile, shipping’s construction code builds a delay repository. Those two facts can coexist because the shipment vocabulary and construction code are different modules.

Selected source imports in the starter. Each arrow means “imports from”; this is not the order of execution.
shipping/deps.py
  → deliveries/delays/__init__.py
  → deliveries/delays/delay_report.py
  → shipping/__init__.py
  → shipping/shipment.py

There is no return path to deps.py here. The shipment concept knows nothing about construction or delays. Drawing all of shipping as one box would hide that distinction.

A convenient re-export can create a cycle

Now imagine making shipping/__init__.py re-export DelayReport to shorten imports. Delay reporting already uses ShipmentId from that root. The root would now depend on code that depends on the root.

# Do not add this to shipping/__init__.py:
from meridian.shipping.deliveries.delays import DelayReport

# Callers use the child's own root instead:
from meridian.shipping.deliveries.delays import DelayReport

A parent can own a family without exporting every member through one module. Keep the parent’s anchor independent of its children. Its separate construction code can connect them through the surfaces it is permitted to use.

09 / Commands, handlers, and reads

Put the decision beside the work

Our dictionary can store shipments, but it does not decide whether registering the same identifier twice is acceptable. Suppose we want to reject that second registration. We need an operation that checks existing records before adding a new one.

A command describes requested work. A handler carries it out. For registration, the command carries the shipment identifier and its two locations. The handler uses shipment storage to check for a duplicate.

# Teaching example: shipping/_commands/register_shipment.py
from dataclasses import dataclass

from meridian.shipping.shipment import (
    Location, Shipment, ShipmentId, Shipments,
)


@dataclass(frozen=True)
class RegisterShipment:
    shipment_id: ShipmentId
    origin: Location
    destination: Location


class RegisterShipmentHandler:
    def __init__(self, shipments: Shipments) -> None:
        self._shipments = shipments

    def handle(self, command: RegisterShipment) -> Shipment:
        if self._shipments.find(command.shipment_id) is not None:
            raise ValueError("A shipment with this identifier already exists.")

        shipment = Shipment(
            command.shipment_id,
            command.origin,
            command.destination,
        )
        self._shipments.add(shipment)
        return shipment

Notice the two kinds of rule. A shipment can check whether its origin differs from its destination by looking at its own values. Registration needs storage to find out whether another shipment already uses the identifier. That decision belongs to the operation.

Both still belong to shipping. The operation imports its own concept module directly; it has no need to go through another owner. Callers outside the private branch use the command surface:

# shipping/commands.py — the convention used in the starter
from meridian.shipping._commands.register_shipment import (
    RegisterShipment,
    RegisterShipmentHandler,
)

In this convention, the command surface and construction code may import the private operation. Routes and concept roots may not, even though they also belong to shipping. Ownership does not make every internal import a good connection.

The check followed by an insert is enough to explain this example’s sequential flow. A database used by concurrent requests would also need to protect identifier uniqueness when it writes. Replacing the storage implementation requires checking those guarantees.

A simple read can remain a simple read

Finding a shipment can be a call to Shipments.find(). A read is often called a query; it does not need a handler simply because a write has one.

A read may eventually deserve its own implementation when it has substantial filtering, paging, or a result assembled from several concepts. Give that work a meaningful name when it appears. The starter’s small tracking read can still be assembled in its route.

Commands and handlers are Python conventions used in these examples. BSA does not require a class for every operation. The important decision is which concept owns the behavior and how callers are allowed to reach it.

10 / Factories and coordinators

Give the operation what it needs

The registration handler needs a shipment repository. It receives one through its constructor rather than choosing or building one itself. An object it relies on is a collaborator. Supplying collaborators this way is called dependency injection.

Someone still needs to assemble those objects. That work is composition. A function that builds or provides an object is a factory. Here is the construction code for the teaching example:

# shipping/deps.py — a single-process teaching example
from functools import lru_cache

from meridian.shipping import InMemoryShipments, Shipments
from meridian.shipping.commands import RegisterShipmentHandler


@lru_cache(maxsize=1)
def get_shipments() -> Shipments:
    return InMemoryShipments()


def get_register_shipment() -> RegisterShipmentHandler:
    return RegisterShipmentHandler(get_shipments())

Add InMemoryShipments to the root exports for this construction code. The factory returns the Shipments contract, so ordinary callers do not need to construct the concrete store themselves. Exporting an implementation for composition does not make construction a business handler’s job.

With database storage, this construction code would instead supply the application’s connection pool to DatabaseShipments. The handler would still receive a Shipments repository and use the same operations.

The repository factory is cached so successive operations in this process use the same dictionary. The handler factory creates a new handler each time. This example’s memory lasts for the process; separate processes have separate stores. An application needing isolated app or request lifetimes must choose an appropriate scope.

Keep connecting work with an appropriate owner

A coordinator connects parts to accomplish some work. In Meridian, shipping/deps.py coordinates construction for the shipping family. A tracking route is another example: it combines supported reads about a shipment, its delivery, and its delays.

Choose the smallest owner that can legitimately connect those parts. It uses their supported surfaces and leaves their individual rules with them. Calling code a coordinator grants no permission to reach into arbitrary private modules.

A function is often enough. The starter’s children do not each need a separate deps.py; shipping’s construction code can build the family. Business handlers receive their collaborators and never import those factories to find dependencies for themselves.

Give callers one useful operation

A coordinator can also make several smaller capabilities useful as a whole. It can use child boundaries and the public vocabulary of other anchors, wherever those connections are allowed. It then exposes an operation through its owner’s public interface. Application code higher up the tree, or another permitted caller, can use that operation without connecting all the pieces itself.

For example, we could give shipping a tracking coordinator. It would read the shipment’s destination through the shipping anchor and ask deliveries and delays for their current information. It could return a ShipmentProgress result containing the destination, journey status, and a summary of delays.

A possible extension to Meridian. These arrows show calls or reads.
API, CLI, or another permitted caller
  → shipping tracking coordinator
      → shipping anchor
      → deliveries public interface
      → delays public interface

The coordinator returns ShipmentProgress to its caller.

If the coordinator also translates those models into the vocabulary its callers need, it acts as a small anti-corruption layer (ACL). That simply means a translation layer: callers use the tracking result without having to understand each child’s status names or data structures. The children still own their rules; the coordinator owns the translation. Combining results alone does not require an ACL.

Expose this operation through a separate, agreed surface, such as shipping/tracking.py. Keep it separate from shipping’s anchor so the shipment definitions remain independent of the children. A small function can do this job; it does not automatically need a new boundary or a coordinator class.

11 / Entry points and adapters

Let the outside world in

A user might register a shipment through an HTTP request, a command line, or a background job. The inputs look different, but the shipment rules should remain the same.

An entry point starts the application in one of those forms. An adapter translates its input into values the business code understands, calls the operation, and presents the result in the format its caller expects.

The Meridian example has api.py, cli.py, and worker.py. All three support shipment registration through the same handler. HTTP returns a response, the CLI prints a message, and the worker reads and writes JSON lines. They reuse business behavior without importing one another.

In our richer location example, an HTTP adapter would turn incoming fields into LocationCode, Coordinates, and Location, then build a registration command. The location and shipment objects would still check their own rules.

A source surface and an HTTP interface are different things. The application imports a router from routes.py; an HTTP client sends requests to that router’s URLs. The client does not import your Python modules.

Keep framework details at the adapter. A shipment does not need to know about FastAPI or HTTP status codes. The starter represents business failures with a code and message, and its HTTP error handler translates those failures into responses. Our teaching snippets use ValueError to keep the early examples small.

12 / Sharing and translation

Share a meaning, not just a name

Delivery and delay code both use ShipmentId to mean the identity of the same shipment. Shipping is a natural owner for that shared definition.

Repeated spelling is weaker evidence. A customer in billing might mean the legal entity responsible for an invoice. A customer in support might mean the person having a conversation. A single shared Customer model could force unrelated responsibilities to change together.

Share a model when you can explain its common meaning and name its owner. If you cannot, separate definitions may be easier to understand. Removing every repeated field is not the goal.

Let locations grow when the work requires it

Our teaching example keeps Location beside shipments because that is where it is needed. Suppose the application later manages depots, opening hours, and location corrections. Location management may now be a distinct responsibility with its own boundary. If other capabilities need locations without using shipping, that is a reason to give location management an independent home. Shipping could use its supported vocabulary in a declared direction.

That would be a new design decision, not a consequence of having introduced a value object. Decide what a location means to each caller before moving it into shared code.

Give technical support a clear job

Clocks, provider clients, and settings also need owners. Meridian places its clock under foundation/. The clock provides time without importing shipment rules. Construction chooses the implementation, and business code receives the clock contract.

Shared errors and HTTP helpers deserve the same care. Describe who may use them and keep them independent of business responsibilities. A shared folder should not become the destination for rules whose owners are unclear.

Translate when two models differ

Suppose a carrier’s API uses its own delivery status codes. A small adapter can translate those codes into your delivery vocabulary. Like the tracking coordinator from chapter 10, this can act as a small anti-corruption layer. The same idea works between your own boundaries and with an external provider: translate when models differ and callers need their own vocabulary.

13 / Growth and migration

Let the next change guide the structure

You can begin using BSA in an existing application with one concept you change often. Find its rules and callers, decide what those callers may use, and move one coherent piece at a time. The first useful step is usually smaller than a complete directory reorganization.

A starting point for placing new code
The changeStart here
A rule about a shipment’s own valuesThe shipment concept.
A rule that needs stored recordsThe operation that owns the decision.
More implementation for an existing operationIts current owner; a private branch can provide room.
A responsibility with its own vocabulary and rulesA new boundary under an appropriate parent.
A policy relating vehicles and shipmentsAssignments, which owns that relationship.
A read combining several conceptsA coordinator allowed to use their surfaces.
A different way to construct a collaboratorThe owning construction code.

Notice when a split is fighting the work

Repeated private imports are a clue. So are two boundaries continually needing each other, or a coordinator collecting most of the business rules. One responsibility may have been divided between owners, or a relationship may have ended up inside one of the definitions it connects.

Moving allocation policy out of vehicle definitions illustrates the repair. Fleet can define vehicles while assignments refer to vehicles and shipments. Ownership and dependency direction then describe the same division of work.

Before moving code, record its existing callers. Define the new public path, move the responsibility, update those callers, and adjust the import checks. Verify the affected behavior after each step. If a split adds only forwarding functions and no distinct responsibility, keeping the code together may be clearer.

14 / Tests and import checks

Check the behavior and the boundaries

A folder tree cannot stop a route from importing a private handler. A test that proves assignment works cannot tell you whether fleet has started importing shipping. These are different questions, so give each an appropriate check.

Exercise the business outcome

Register a vehicle and two shipments. Assign the vehicle to the first shipment, then try assigning it to the second. The second attempt should fail and the first assignment should remain. The starter tests that scenario through the real handler and its in-memory repositories.

Its test setup uses the same construction path as the application. When an outside dependency needs replacing, a typed fake provides its contract. A test clock, for example, supplies predictable time. Tests can assert outcomes and stored state rather than how many times a mock was called.

Exercise the import rules too

Meridian uses Tach and a source-convention checker to check declared directions, private access, and rules such as using another concept’s public root. The restrictions live beside the application and run with its other checks.

The starter also adds forbidden imports to disposable source copies and verifies that they are rejected. For example, fleet importing shipment vocabulary should fail because of the declared direction. A failure caused only by an unrelated syntax error would not show that the boundary rule works.

Keep allowed imports working, and check that new modules are covered. A passing linter result can only speak for the code and connections its configuration actually examines.

Human review still matters. A checker can enforce “fleet must not import shipping”; it cannot decide whether assignments are a sensible owner for allocation policy. Review the meaning of the boundary and the types required by its public interface alongside the automated results.

15 / Everyday use

Make the next change

Once owners and connections are clear, you have a repeatable way to work. For a change to vehicle assignment:

  1. Start with the decision. Describe the behavior that must change, then open assignments.
  2. Read the rule and its callers. Follow relevant imports and find uses of any name you plan to change.
  3. Check new connections. Confirm that the target surface permits this caller and direction.
  4. Make the change. Keep the decision with its owner. Update public types and callers when the behavior requires it.
  5. Verify the result. Run behavior and import checks. Explain the changed behavior and what you verified.

Begin reading locally and widen the search when you need context. A restriction on importing a private file is not a restriction on reading it. Understanding an unfamiliar dependency may require inspecting its implementation.

Work through a possible next feature: reassignment

Suppose a vehicle must move from one shipment to another. This is a proposed feature, not an operation in the starter.

Begin in assignments and establish the rules for the change. Use fleet and shipping through their public vocabulary to validate the records. Expose the operation through the command surface, and supply its collaborators in shipping’s construction code. Test both the successful change and that rejection leaves the original assignment intact. Fleet still has no reason to import shipping.

The same workflow helps when you work with a coding agent. Record your project’s decisions so it has fewer things to guess. Those instructions and the tested examples show how the project is organized; they do not by themselves establish improvements in agent accuracy, time, or cost.

Reference / Vocabulary

The terms, in one place

Concept
Something with a meaning in the work your software does, such as a shipment or an assignment.
Value object
An object described by its values, such as a location or coordinates. Our examples keep those values immutable.
Invariant
A condition that must remain true, such as a shipment having different origin and destination codes.
Boundary
An area of source code responsible for a coherent part of the application.
Direct owner
The first boundary you reach when walking up the folders from a file. That boundary owns the file’s implementation.
Anchor
The central domain concept whose public vocabulary lets other boundaries connect to it.
Private branch
Implementation belonging to an existing owner, accessed only through declared paths.
Child boundary
A distinct responsibility within exactly one parent, with its own rules and supported connections.
Surface and audience
The interface a boundary supports, and the callers allowed to use it.
Contract
The operations a collaborator promises to provide. An implementation does the work.
Command and handler
A request to perform work, and the code that carries it out.
Query
A read. Simple reads can use a repository contract directly.
Coordinator
Code that brings parts together through their supported interfaces. It can expose a combined operation to other callers.
Anti-corruption layer (ACL)
A translation layer that turns another model into the vocabulary its callers need. A coordinator can provide this translation.
Factory and composition
A factory builds or provides an object. Composition assembles objects and their collaborators.
Dependency injection
Giving an operation its collaborators instead of making it find or build them.
Entry point and adapter
An entry starts an application. An adapter translates external input and output around business operations.

Reference / Related ideas

Place BSA alongside familiar approaches

These ideas have useful relatives. Robert C. Martin’s Clean Architecture describes keeping source dependencies directed toward business policy. Jimmy Bogard’s Vertical Slice Architecture groups the work of a use case along the changes it needs. BSA focuses on giving concepts clear source owners and spelling out the permitted connections between them.

A DDD bounded context gives a model a scope within which its terms have a consistent meaning. A BSA source boundary can be much smaller. An assignments package does not automatically represent a separate bounded context.

BSA developed from the ideas in Structure for the Unstructured. The aim is a useful way to organize source and review changes. This guide does not establish that it outperforms other approaches.