Loading learning content...
Every well-designed software system is defined not just by what it does, but by what it hides. The most elegant architectures are those where each component presents a simple, comprehensible interface while concealing vast complexity within. But how do experienced engineers know where to draw these boundaries? How do they decide what to hide and what to expose?
This question—where should abstraction boundaries exist?—is one of the most consequential decisions in software design. Draw the boundary in the wrong place, and you create either a leaky abstraction that fails to simplify anything, or an over-abstraction that adds ceremony without value. Draw it correctly, and you create a system that remains comprehensible, maintainable, and evolvable for years.
This page teaches you to see what experienced architects see: the natural seams in a system, the indicators of good boundaries, and the techniques for identifying where abstractions should live.
By the end of this page, you will understand how to identify natural abstraction boundaries in software systems, recognize the signs that indicate where boundaries should exist, and apply systematic techniques for finding the right level of abstraction in your designs.
An abstraction boundary is a conceptual line in your codebase that separates the what from the how. On one side of the boundary lies an interface—a description of capabilities. On the other side lies an implementation—the actual mechanisms that deliver those capabilities.
To understand this concretely, consider your car's steering wheel. The steering wheel presents a simple interface: turn left to go left, turn right to go right. Behind that interface lies an incredibly complex implementation: hydraulic or electric power steering systems, rack and pinion mechanisms, tie rods, ball joints, and sophisticated electronic stability controls. The steering wheel is an abstraction boundary—it separates what you want to do (change direction) from how it's accomplished (mechanical and electronic systems).
An abstraction boundary exists to allow one part of a system to change without affecting other parts. The power steering system in a car evolved from hydraulic to electric, but the steering wheel interface remained identical. That's the power of a well-placed boundary.
In software, abstraction boundaries manifest in several forms:
Function signatures — The function name and parameters form a boundary; the function body is the hidden implementation.
Class interfaces — Public methods define what a class can do; private methods and fields hide how it works.
Module interfaces — Export statements declare the public surface; internal files remain hidden.
API contracts — Request/response schemas define interactions; server implementation is completely hidden.
Service boundaries — The service's API (REST, gRPC, GraphQL) is the interface; everything behind it is abstracted away.
Each of these represents a different scale of abstraction, but they all share the same fundamental structure: a stable, defined interface shielding a changeable implementation.
| Scale | Interface | Implementation Hidden | Change Insulation |
|---|---|---|---|
| Function | Name + parameters | Algorithm, local variables, helper calls | Can optimize without changing callers |
| Class | Public methods | Private state, internal methods | Can refactor internals freely |
| Module/Package | Exports | Internal structure, dependencies | Can restructure without affecting importers |
| Service | API contract | Database schema, libraries, languages | Can rewrite entirely without client changes |
| System | User-facing interface | Entire backend architecture | Can completely re-architect invisibly |
Abstraction boundaries aren't arbitrary lines drawn through code—they reflect natural seams in the problem domain. Experienced engineers develop an intuition for recognizing these seams. Here are the key indicators that suggest where boundaries should exist:
Rate of Change Deserves Special Attention
Of all the indicators, rate of change is perhaps the most reliable guide. Parnas's foundational paper on information hiding (1972) established this principle: hide the design decisions most likely to change.
Consider a typical web application:
| Component | Rate of Change | Change Drivers |
|---|---|---|
| UI styling | Weekly | Designer feedback, brand updates |
| Business rules | Monthly | New features, regulatory compliance |
| Database schema | Quarterly | Performance optimization, new features |
| Core domain model | Yearly | Major product pivots |
| Infrastructure | Years | Technology migrations |
This analysis immediately suggests boundaries: UI code should be separated from business logic (different rates), business logic from database access (different drivers), and all of these from infrastructure (longest stability).
When evaluating a design, ask: 'If I need to change X, what else must change with it?' If changing the database requires modifying business logic, your boundaries are in the wrong place. If adding a new payment provider requires rewriting order processing, your abstraction is leaking. The goal is to minimize the ripple effect of changes.
One of the most reliable heuristics for identifying abstraction boundaries is to listen to how people naturally talk about the system. This is the Vocabulary Principle: abstraction boundaries should align with natural language boundaries in problem domain conversations.
When domain experts discuss a system, they use specific terminology for different concerns:
Each of these natural language concepts suggests an abstraction boundary. When you find yourself saying 'the X system' or 'the Y component,' you're identifying a natural boundary in the vocabulary.
The vocabulary principle extends to Domain-Driven Design's concept of Bounded Contexts. In DDD, a bounded context is a boundary within which a particular model (vocabulary) applies. The word 'Account' might mean something different in the 'Banking' bounded context versus the 'Social Media' bounded context. These natural vocabulary boundaries become explicit abstraction boundaries in the architecture.
Practical Application:
This linguistic analysis often reveals abstraction boundaries more clearly than any technical analysis.
Domain-Driven Design calls this the 'Ubiquitous Language'—a shared vocabulary between developers and domain experts. Abstraction boundaries should respect this language. If domain experts talk about 'Orders' and 'Shipments' as separate concepts, your code should have separate abstractions for them.
Abstraction boundaries aren't just about what to hide—they're about how dependencies flow across those boundaries. The Dependency Direction Principle states: dependencies should flow from unstable (frequently changing) toward stable (rarely changing) components.
This principle has profound implications for where boundaries should exist. Consider a typical application structure:
┌─────────────────────────────────────────────────────────────┐│ USER INTERFACE ││ (Changes frequently - unstable) ││ │ ││ ▼ ││ ┌─────────────────────────────┐ ││ │ APPLICATION SERVICES │ ││ │ (Moderate stability) │ ││ └─────────────────────────────┘ ││ │ ││ ▼ ││ ┌─────────────────────────────┐ ││ │ DOMAIN MODEL │ ││ │ (Most stable - core) │ ││ └─────────────────────────────┘ ││ ▲ ││ │ ││ ┌─────────────────────────────┐ ││ │ INFRASTRUCTURE │◄── Dependencies ││ │ (Database, APIs, etc.) │ point INWARD ││ └─────────────────────────────┘ │└─────────────────────────────────────────────────────────────┘ Dependencies flow TOWARD the center (stable core)Outer layers depend on inner layers, never the reverseThis is the essence of Clean Architecture, Hexagonal Architecture, and Onion Architecture—they all share the same core insight: stable, high-value code should be at the center, with dependencies pointing inward.
Why does this matter for boundary identification?
When you draw a boundary, you're creating a dependency relationship. The code that uses an abstraction depends on the abstraction's interface. If that interface is unstable (changes frequently), all dependent code must change too.
Therefore, abstractions should:
If your business logic depends on database details (specific SQL, ORM entities), your dependencies are inverted. The domain—the most stable, valuable part—is now coupled to infrastructure—which might change for purely technical reasons. This is why repositories and adapters exist: to invert the dependency so business logic remains insulated.
Practical Example: Repository Pattern
Consider how the Repository pattern creates proper dependency direction:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
// === DOMAIN LAYER (Stable - the center) ===// This is the most stable code in your system.// It knows NOTHING about databases, frameworks, or external services. interface UserRepository { findById(id: UserId): Promise<User | null>; findByEmail(email: Email): Promise<User | null>; save(user: User): Promise<void>; delete(id: UserId): Promise<void>;} // The User entity - pure domain logicclass User { constructor( private readonly id: UserId, private email: Email, private name: string, private readonly createdAt: Date ) {} changeEmail(newEmail: Email): void { this.email = newEmail; // Domain event, validation, etc. }} // === INFRASTRUCTURE LAYER (Unstable - outer ring) ===// This code IMPLEMENTS the interface but DEPENDS ON the domain.// Dependencies point INWARD toward the domain. class PostgresUserRepository implements UserRepository { constructor(private db: DatabaseConnection) {} async findById(id: UserId): Promise<User | null> { const row = await this.db.query( 'SELECT * FROM users WHERE id = $1', [id.value] ); return row ? this.toDomain(row) : null; } async save(user: User): Promise<void> { await this.db.query( 'INSERT INTO users (id, email, name) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE...', [user.id.value, user.email.value, user.name] ); } private toDomain(row: DbRow): User { return new User( new UserId(row.id), new Email(row.email), row.name, row.created_at ); }} // === APPLICATION LAYER ===// Depends on the domain interface, not the implementation.// Can work with any repository implementation. class UserService { // Injected via interface - doesn't know about Postgres constructor(private userRepo: UserRepository) {} async changeUserEmail(userId: UserId, newEmail: Email): Promise<void> { const user = await this.userRepo.findById(userId); if (!user) throw new UserNotFoundError(userId); user.changeEmail(newEmail); await this.userRepo.save(user); }}The classic metrics of cohesion (how strongly related elements within a module are) and coupling (how dependent modules are on each other) provide a rigorous framework for identifying abstraction boundaries.
The goal is clear: maximize cohesion within boundaries, minimize coupling across them.
When you draw a boundary that encloses highly cohesive elements, you create a meaningful abstraction. When you draw a boundary that minimizes coupling to other parts of the system, you enable independent change and evolution.
| Type | Description | Quality |
|---|---|---|
| Functional | All elements contribute to a single, well-defined task | ⭐ Best |
| Sequential | Output of one element is input to the next | Good |
| Communicational | Elements operate on the same data | Good |
| Procedural | Elements are related by order of execution | Moderate |
| Temporal | Elements are related by being executed at similar times | Poor |
| Logical | Elements are logically related but do different things | Poor |
| Coincidental | No meaningful relationship between elements | ⚠️ Worst |
Practical Cohesion Analysis
To analyze whether elements belong together (high cohesion), ask these questions:
Single Purpose Test: Can you describe what this module does with a single, coherent sentence without using 'and'?
Change Together Test: When requirements change, do these elements change together?
Conceptual Unity Test: Do these elements form a coherent mental model?
Draw a dependency diagram for your system. Boundaries should cut through areas with few connections (low coupling). If you find yourself drawing a boundary that cuts through many dense connections, you're probably in the wrong place—those densely connected elements should likely be inside the same boundary.
Identifying where boundaries should exist is only half the skill. Equally important is recognizing where your current boundaries are wrong. Here are seven signals that indicate boundaries need to move:
Case Study: Shotgun Surgery
Imagine adding a new field to your User entity:
| Layer/Module | Changes Required |
|---|---|
| Database migration | Add column |
| ORM Model | Add property |
| Repository | Update mapping |
| Domain Entity | Add field |
| DTO | Add field |
| Controller | Update validation |
| Frontend | Display new field |
Some of these changes are unavoidable, but if you also have to change:
...then you have shotgun surgery. Your boundaries are wrong. The user concept should be encapsulated such that adding a field doesn't ripple across unrelated modules.
Finding the right boundaries isn't a one-time activity. As systems evolve, the right boundaries change. Code that was appropriately coupled initially may need separation as requirements diverge. Regular refactoring to adjust boundaries is a sign of a healthy codebase.
Beyond intuition and heuristics, several systematic techniques help identify where abstraction boundaries should exist:
Event Storming is a collaborative technique that identifies domain events and the boundaries between them.
Process:
Example Output:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ ORDERING │ │ PAYMENT │ │ SHIPPING │
│ │ │ │ │ │
│ Order Placed │ │ Payment Init │ │ Label Created │
│ Order Modified │──▶ Payment Success │──▶ Item Picked │
│ Order Cancelled │ │ Payment Failed │ │ Item Shipped │
│ │ │ Refund Issued │ │ Item Delivered │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Natural clusters of events reveal natural abstraction boundaries.
Combining Techniques
No single technique perfectly identifies all boundaries. The best approach combines multiple methods:
When multiple techniques point to the same boundaries, you can be confident in your design.
Identifying the right abstraction boundaries is fundamental to creating maintainable, evolvable software. Let's consolidate the key principles:
What's Next:
Now that we can identify where abstraction boundaries should exist, the next page explores how to effectively hide implementation details behind those boundaries. We'll examine the techniques and patterns that ensure your abstractions remain clean and your implementations remain changeable.
You now understand how to identify natural abstraction boundaries in software systems. These skills form the foundation for designing clean, maintainable architectures. In the next page, we'll learn how to properly hide implementation details behind these boundaries.