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.pyMoving 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.
# 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 ShipmentIdThe 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
Shipping
└── Deliveries
├── Assignments
└── DelaysFollow 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?
| How the capability is used | What 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.
| Surface | What callers use it for |
|---|---|
__init__.py | Permitted domain code imports vocabulary such as ShipmentId and Shipments. |
commands.py | Entry code imports operations. The starter exports command values and handler types here. |
deps.py | Entry and construction code obtains the objects needed to perform work. |
routes.py | The 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, VehiclesPython 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
VehicleIdidentifies a vehicle.Vehiclesdescribes 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.
Shipping
├── Shipment concept
├── Construction code (deps.py)
└── Deliveries
├── Delivery concept
├── Assignments
└── DelaysA 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.
shipping/deps.py
→ deliveries/delays/__init__.py
→ deliveries/delays/delay_report.py
→ shipping/__init__.py
→ shipping/shipment.pyThere 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 DelayReportA 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 shipmentNotice 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.
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.
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.
| The change | Start here |
|---|---|
| A rule about a shipment’s own values | The shipment concept. |
| A rule that needs stored records | The operation that owns the decision. |
| More implementation for an existing operation | Its current owner; a private branch can provide room. |
| A responsibility with its own vocabulary and rules | A new boundary under an appropriate parent. |
| A policy relating vehicles and shipments | Assignments, which owns that relationship. |
| A read combining several concepts | A coordinator allowed to use their surfaces. |
| A different way to construct a collaborator | The 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:
- Start with the decision. Describe the behavior that must change, then open assignments.
- Read the rule and its callers. Follow relevant imports and find uses of any name you plan to change.
- Check new connections. Confirm that the target surface permits this caller and direction.
- Make the change. Keep the decision with its owner. Update public types and callers when the behavior requires it.
- 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.