Grokking Modern Software Architecture Styles
Vote

0% completed

​

Responsibilities and Boundaries

Know the Vocabulary

Part 1: What a Responsibility Is

Part 2: Signs of a Poorly Assigned Responsibility

Sign 1: One Component Does Several Unrelated Jobs

Sign 2: One Job Is Spread Across Several Components

Sign 3: Two Components Must Always Change Together

Part 3: Separation of Concerns at the Component Level

Part 4: Boundaries and Interfaces

Part 5: Two Ways to Split a System

Part 6: Boundaries Inside One Deployable and Across the Network

FAQs

Key Takeaways

Know the Vocabulary

This lesson uses the terms below repeatedly. Read them once now and return here when a later section needs one.

  • Responsibility: A job that one part of the system owns, together with the data that job needs and the decisions it makes.
  • Component: Any part of a system that has its own responsibility, such as the Payments module or the Courier app.
  • Separation of concerns: Keeping each kind of work, such as request handling, business rules and storage, in its own part of the code.
  • Boundary: The limit of a component, which states which code and data belong inside it and are therefore hidden from every other component.
  • Interface: A declaration of the operations a component provides to other components, with their inputs, outputs and errors, and no implementation. For a class, it lists the methods the class must provide; in Python, an abstract base class plays this role.
  • Information hiding: Keeping a component's data and internal logic inaccessible to other components, so that they can use it only through its interface.
  • Layer: A horizontal slice of the code with one technical job, such as handling HTTP or talking to the database.
  • Module: A group of related code, such as everything about orders, with a defined interface that other code calls.
  • Business capability: One thing the business does for its customers or partners, such as taking orders, publishing menus or paying couriers.
  • Modular monolith: One deployable application whose code is split into modules by business capability, each with its own interface and its own data.
  • Service: A component that runs as its own program, is deployed on its own, and is called by other components over the network.
  • Deployable artifact: One packaged build of an application, such as a JAR file or a Docker image, shipped to servers as a unit.
  • Coupling: The degree to which a change in one part of a system forces a change in another part.

A developer on the food-delivery app is asked to change one rule: the delivery fee should depend on distance instead of being a flat $2.99. The request sounds like a single edit. After a day of searching, the developer finds the fee calculated in three places. The Customer app computes an estimate, Orders computes the amount it charges, and Payments computes the courier's share. Two of the three copies already disagree about rounding.

Every line of that code does what its author intended. The problem is that no single part of the system owns the delivery fee, so nobody can change it in one place.

This lesson is about deciding which part of a system owns each job and what the other parts are allowed to see. It covers responsibilities, boundaries, interfaces, and the two common ways to split a system into parts. Every architecture style in this course gives its own answer to these questions, so the terms defined here appear throughout the later lessons.

Part 1: What a Responsibility Is

A responsibility is a job, plus the data and the decisions that come with it. "Charge the customer" is a job. For Payments to own that job, it must keep the data the job needs: payment records and the tokens that the payment provider returns. A token is a string the provider issues in place of the card number, so the app never stores the card itself.

Payments must also make the decisions the job involves, such as whether to retry a declined card. A component that does the work but asks another component for every decision does not own the job. It only carries out someone else's.

A good responsibility fits in one sentence. A fixed template makes the test mechanical: "Component X owns job Y; it keeps data Z and decides W." If the sentence needs an "and" that joins two unrelated jobs, the component probably holds two responsibilities. If the sentence cannot be written without naming another component's data, the boundary is probably in the wrong place.

For example: "Dispatch owns courier assignment; it keeps courier locations and decides which courier gets each order."

The table below applies the template to the six business components of the food-delivery app. The three client apps are left out. Their job is presentation: they show data and collect input, but they own no business decisions.

ComponentJobData it keepsDecisions it makes
OrdersTurn a cart into a placed order and track it to deliveryorders, order items, status historywhether an order can be placed, the delivery fee, the next status
MenuPublish what each restaurant sells and at what pricerestaurants, menu items, prices, opening hourswhether an item is available, what it costs now
PaymentsCharge customers and pay restaurants and courierspayment records, provider tokens, refunds, payoutswhether to retry a charge, how much to refund, each courier's share
DispatchAssign a courier to each ready ordercourier locations, assignmentswhich courier gets which order, when to reassign
NotificationsDeliver messages to customers, restaurants and courierstemplates, delivery attempts, channel preferenceswhich channel to use, when to retry, when to stay silent
AccountsKnow who each user is and what each user may dousers, roles, addresses, login sessionswhether a login is valid, which actions a role allows

Every piece of data in the third column appears exactly once. Each fact has one owner, and every other component that needs it asks that owner.

Part 2: Signs of a Poorly Assigned Responsibility

Responsibilities are rarely misplaced on purpose. They become misplaced as features are added, one reasonable edit at a time. Three signs reveal the problem, and each one has appeared in the food-delivery app.

The diagram below shows the three signs side by side, with two symptoms and one repair for each.

Three Signs of a Poorly Assigned Responsibility
Three Signs of a Poorly Assigned Responsibility

Sign 1: One Component Does Several Unrelated Jobs

Over 18 months, the Orders module took on two extra jobs. It now computes each courier's weekly earnings, and it formats the SMS text sent when an order is ready. Its main file has grown to about 4,000 lines (an illustrative figure). Three teams now edit it: the team that owns checkout, the team that pays couriers, and the team that sends messages.

The cost appears in every release. A change to the wording of one SMS needs a review from the team that owns checkout and a redeploy of the checkout code. The module has three separate reasons to change, which is the clearest sign that it holds three responsibilities. The repair is to move courier earnings to Payments and message formatting to Notifications, so that Orders keeps only the order.

Sign 2: One Job Is Spread Across Several Components

The delivery fee from the opening story is the example here. The business replaces the flat 2.99 fee with 1.99 plus 0.50 for every started kilometer. A 3 km order now costs 1.99 + 1.50 = 3.49. If Orders is updated but the Customer app is not, the customer sees 2.99 on the checkout screen and is charged 3.49. Every one of those orders can turn into a complaint and a refund.

A job spread across components has no owner, so every change to it needs a search and several coordinated edits. The repair is to give the job one owner. Orders computes the fee, and the Customer app asks Orders for a quote before showing a price. Payments reads the fee stored on the placed order instead of computing it again.

Sign 3: Two Components Must Always Change Together

At checkout, Orders reads prices straight from the menu_items table that Menu owns, using its own SQL query. When an engineer working on Menu renames the price column to price_cents, Orders starts failing as soon as the change is deployed. In this example, 8 of the last 10 changes to Menu needed a matching edit in Orders (illustrative figures).

Two components that always change together have one of two problems. Either they are one responsibility split in two, or one of them reads the other's private data. Here the second is true, so the repair is for Orders to ask Menu for prices through Menu's interface, which Part 4 shows in code. Reading another component's tables is a strong form of coupling. The Coupling, Cohesion, and Dependencies lesson in this chapter classifies its kinds.

Part 3: Separation of Concerns at the Component Level

Separation of concerns is often applied to the code that handles one request. The Layered (N-Layer) Architecture lesson applies it that way: a controller handles HTTP, a service applies business rules, and a repository talks to the database. The same rule applies one level higher, between components. Each component should handle one kind of work and leave other kinds to their owners.

A concern is a kind of work, and a useful way to find one is to ask why the code would change. Code that changes because the business changes its refund policy is one concern. Code that changes because the SMS provider changes its API is another.

Orders decides that a message is needed; Notifications decides how it is delivered. When an order leaves the restaurant, Orders tells Notifications that order 812 is on its way. Notifications chooses the channel (push, SMS or email), writes the text in the customer's language, and retries 3 times over 10 minutes if delivery fails. It also sends no SMS between 10 PM and 8 AM, a period called quiet hours. If Orders sent the SMS itself, every change to quiet hours would become a change to Orders.

Only Payments talks to the external payment provider. If Orders also called the provider to check a charge, switching providers would change two components instead of one. Wrapping an external system is a concern of its own.

Some concerns are needed by every component, such as logging and checking that a request is authenticated. A concern of this kind is called a cross-cutting concern. It usually lives in a shared library that every component uses, so it is still defined in one place.

When a requirement changes, count the components you must edit. A requirement that belongs to one concern should touch one component.

Part 4: Boundaries and Interfaces

A boundary marks what is hidden. Inside the boundary of the Menu module sit 9 database tables, the SQL that reads them, a cache of the 500 most-ordered items, and the code that imports menus from restaurant spreadsheets (illustrative figures). No other component can see any of it. Keeping these details inaccessible to other components is called information hiding.

An interface states what is promised. For each operation, the interface gives its name, the inputs it accepts, the output it returns, and the errors it can raise. Menu promises three operations: get one item, list the items of one restaurant, and mark an item as sold out. The interface states what each operation does and says nothing about how.

The interface should be much smaller than what it hides. Every operation and every field in an interface is something another component can depend on. Each one is therefore something the owner can no longer change alone. Suppose Menu has about 40 functions, and 3 of them form its interface. The engineers who own Menu can rewrite any of the other 37 functions, or restructure all 9 tables, without asking anyone. Changing any of the 3 operations needs agreement from every caller.

A component with a small interface over a large body of behavior is sometimes called a deep module. The opposite, a shallow module, has an interface almost as large as what it hides. A component that exposes its tables is the extreme case, which is exactly the situation in Sign 3.

The diagram below shows the Menu module's boundary. Orders and two client apps call only the three operations in front, and the rules, cache, import code and tables remain hidden behind them.

A Narrow Interface in Front of Hidden Internals
A Narrow Interface in Front of Hidden Internals

The code below shows the Menu module's interface. Orders imports only Menu and MenuItem, and everything else stays inside the module.

Python3
Python3
. . . .

Three details in that code do the hiding. First, the table stores sold_out, but the interface returns is_available, so Menu can rename or replace the column without any caller noticing. Second, get_item returns a MenuItem object instead of a database row, so callers cannot depend on columns that the interface does not list. Third, the connection is private: in Python the leading underscore on _db marks it as private by convention, and the other languages declare the field private or unexported. Orders now calls menu.get_item(42).price_cents, and the repair for Sign 3 is complete. The third operation, list_items, follows the same pattern and is left out to keep the example short.

Part 5: Two Ways to Split a System

Once each responsibility has an owner, the code still has to be arranged into parts. There are two common ways to draw the main boundaries: by technical job or by business area.

A split by technical job produces layers. The presentation layer receives requests and formats responses for all three client apps. The business logic layer holds the rules for orders, menus, payments and every other area. The data access layer reads and writes every table in the PostgreSQL database. The folders are controllers/, services/ and repositories/, and each one contains code for every business area. The Layered (N-Layer) Architecture lesson teaches this split in depth.

A split by business area produces modules. The folders are orders/, menu/, payments/, dispatch/, notifications/ and accounts/. Each module contains its own request handling, its own rules and its own data access, and it owns its own tables in the shared database. When this split is deployed as one application, it is a modular monolith, which the Monolith Architecture lesson describes in detail.

The diagram below shows the same food-delivery backend split both ways, with four of the six business areas drawn. The Menu code is outlined: on the left it is cut across three layers, and on the right it sits inside one module.

The Same App Split by Layer and by Business Area
The Same App Split by Layer and by Business Area

Each split makes a different kind of change local. Consider two requests that arrive in the same week.

The first request lets restaurants mark an item as sold out for the rest of the day. In the layered split, the change touches controllers/, services/ and repositories/. Those are three folders, which may belong to three different teams. In the modular split, every edit stays inside menu/.

The second request adds a 2-second timeout to every database query. In the layered split, the change sits in one place, the data access layer. In the modular split, it touches six modules, unless the team has already moved database access into a shared library.

The table below compares the two splits.

QuestionSplit by technical job (layers)Split by business area (modules)
Change to one feature, such as sold-out itemsTouches every layerStays inside one module
Change to one technical concern, such as query timeoutsStays inside one layerTouches every module
Team ownershipTeams own layers, so one feature needs several teamsOne team can own a whole module
Rule that is easy to enforcePresentation code never calls the databaseNo module reads another module's tables
Moving one part into its own service laterHard, because each business area is spread over every layerEasier, because a module already has an interface and its own data
Main risk as the code growsBusiness rules get written into the presentation and data layersTechnical code is repeated in every module

Most systems combine the two. They split by business area at the top level of the code and use layers inside each module, so orders/ holds its own controllers, rules and repositories. The Microservices Architecture lesson keeps the business-area split and places each module in a service of its own.

Part 6: Boundaries Inside One Deployable and Across the Network

A boundary can sit between modules inside one deployable artifact, or between services that talk over the network. The interface can look identical in both cases, but crossing the boundary costs very different amounts.

A module boundary is crossed with a function call. Both sides run in the same program, so the call itself from Orders to Menu adds well under a microsecond. Both sides also fail together, and one database transaction can cover writes made by both modules. The boundary is enforced by convention, by code review, and by tools that fail the build when one module imports another module's internals. A single wrong import can cross it, but moving it is one refactor in one pull request.

A service boundary is crossed with a network call. A call between two services in one data center typically takes roughly 0.5 to a few milliseconds once serialization (converting the data to bytes) is included. Either side can also fail while the other keeps running. The caller therefore needs timeouts and retries, and no single database transaction covers both sides. The boundary is enforced by the network itself, because Orders has no connection to Menu's database. Moving it means changing an API, migrating data and coordinating releases of two services.

Checkout in the food-delivery app shows the difference with numbers. It crosses four boundaries: Menu for prices, Accounts for the delivery address, Payments for the charge, and Dispatch to request a courier. As modules, the four crossings are function calls that add well under 1 ms in total. As services called one after another at an illustrative 3 ms each, they add about 12 ms, plus code to handle each one failing. Availability also changes. Suppose each of the four services is available 99.9 percent of the time and failures are independent. All four calls then succeed 0.999 to the fourth power, or about 99.6 percent, of the time, so checkout fails about four times as often as any one service does. The Quality Attributes lesson in this chapter explains how such numbers are measured.

AspectModule boundary (inside one deployable)Service boundary (across the network)
Cost of one crossingFunction call, under a microsecondNetwork call, roughly 0.5 to a few ms
FailureBoth sides fail togetherEither side can fail alone
One transaction across both sidesYesNo, so writes need extra coordination
How the boundary is enforcedConventions, review, import checksThe network and separate databases
DeploymentReleased togetherReleased independently
Cost of moving the boundaryOne refactorAPI change, data migration, coordinated releases

The practical order follows from that table. Draw boundaries as modules first, because a module boundary in the wrong place is cheap to move. Watch how each boundary behaves as the product changes. A module boundary that has stayed the same for many months is a candidate for a service boundary, if a specific need justifies the cost. Separate releases are such a need, and so is a scaling need that cannot be met by running part of the same application on machines of its own. A boundary that keeps changing should stay a module. The Monolith Architecture lesson's section on extracting services describes the signals that justify that step.

FAQs

Q: Is a responsibility the same as the single responsibility principle?

They are the same idea at different sizes. The single responsibility principle says that a class should have only one reason to change. This lesson applies the same test to whole components, so one component with one responsibility can still contain dozens of classes.

Q: How large should a module be?

There is no fixed line count. A module should cover one business capability that one team can own and explain in one sentence. If two modules always change together, consider merging them; if one module has three reasons to change, consider splitting it.

Q: Who owns data that many components need, such as a customer's address?

Exactly one component owns it, and in the food-delivery app that owner is Accounts. Other components ask Accounts through its interface or keep a read-only copy that they refresh when Accounts announces a change. Orders does store the address on each order, but that copy is a record of where this order went, so it belongs to Orders.

Q: Is a layer the same as a tier?

No. A layer is a way of organizing code, and several layers usually run inside one program. A tier is a part of the system that runs on its own machines and talks to the other parts over the network. The N-Tier Architecture lesson covers tiers in full.

Q: Can a client app hold a business rule?

A client app may repeat a rule to give faster feedback. For example, the Customer app can show "minimum order $10" before the customer taps the order button. The backend component still owns the rule and checks it again, because an old app version or a modified app could send any request.

Key Takeaways

  • A responsibility is a job with its data and its decisions. A component that does the work but asks another component for every decision does not own the job.
  • State each responsibility in one sentence. If the sentence needs an "and" between unrelated jobs, or names another component's data, the responsibility is assigned badly.
  • Three signs show a misplaced responsibility: one component with several unrelated jobs, one job spread across several components, and two components that always change together.
  • Separation of concerns applies between components, not only within the code for one request. A requirement that belongs to one concern should touch one component.
  • A boundary hides; an interface promises. Keep the interface much smaller than what it hides, because every item in it is something the owner can no longer change alone.
  • Splitting by technical job gives layers; splitting by business area gives modules. Layers make technical changes local, modules make feature changes local, and most systems use modules at the top level with layers inside each one.
  • A module boundary is cheap to cross and cheap to move; a service boundary is neither. A network crossing adds milliseconds and new failures, and moving a service boundary needs API changes and data migration.
  • Draw boundaries as modules first. Turn a module into a service only when its boundary has not changed for months and a specific need justifies the cost.

The next lesson in this chapter, Coupling, Cohesion, and Dependencies, measures how strongly the components drawn here depend on one another. It also shows which way those dependencies should point.

Reading Progress

0%


Vote for new content

On This Page

Know the Vocabulary

Part 1: What a Responsibility Is

Part 2: Signs of a Poorly Assigned Responsibility

Sign 1: One Component Does Several Unrelated Jobs

Sign 2: One Job Is Spread Across Several Components

Sign 3: Two Components Must Always Change Together

Part 3: Separation of Concerns at the Component Level

Part 4: Boundaries and Interfaces

Part 5: Two Ways to Split a System

Part 6: Boundaries Inside One Deployable and Across the Network

FAQs

Key Takeaways