Loading content...
Every complex software system that scales, survives, and evolves shares a common trait: it is built from well-defined, manageable pieces. This isn't an accident or a stylistic choice—it's a fundamental engineering principle that separates professional-grade software from fragile, monolithic codebases that collapse under their own weight.
At the heart of Low-Level Design lies a deceptively simple idea: decomposition. The ability to take a complex problem, break it into smaller sub-problems, solve each in isolation, and compose them back into a coherent whole. Yet, while the concept sounds straightforward, mastering it requires developing an intuition that only comes from understanding why decomposition matters and how to do it correctly.
By the end of this page, you will understand the principles of system decomposition, recognize the cognitive and engineering benefits of working with small pieces, learn systematic approaches for breaking down problems, and develop the intuition to identify natural component boundaries in any software system.
Before diving into techniques, we need to understand why decomposition isn't just a good practice but an absolute necessity for any non-trivial software project. The reasons are rooted in both human cognitive limits and fundamental engineering constraints.
The Cognitive Constraint: Miller's Law
Cognitive psychologist George Miller famously established that humans can hold approximately 7 ± 2 items in working memory simultaneously. This isn't a suggestion for software design—it's a hard constraint on what any individual developer can reason about at once.
Consider a codebase with 100,000 lines of code intertwined as a single, monolithic unit. No human can understand its complete state at any moment. Now consider the same functionality decomposed into 200 modules of 500 lines each, where each module has clear inputs, outputs, and responsibilities. A developer can fully understand any single module, trace its connections, and modify it confidently.
When designing a class or module, aim for no more than 7 public methods. When designing a system, aim for no more than 7 major components interacting directly. This isn't a strict rule but a heuristic—if you're exceeding these numbers, consider whether you're asking too much of human comprehension.
The Engineering Constraint: Complexity Growth
As systems grow, the number of potential interactions between components grows quadratically. If you have n components that can all interact with each other, you have up to n(n-1)/2 possible connections.
| Components | Potential Connections |
|---|---|
| 5 | 10 |
| 10 | 45 |
| 20 | 190 |
| 50 | 1,225 |
| 100 | 4,950 |
This explosive growth in complexity is why poorly decomposed systems become unmanageable. Every change potentially affects thousands of interactions. Bugs become impossible to trace. Testing becomes infeasible.
A system with 100 tightly-coupled components has nearly 5,000 potential interactions to consider. No team can manage this. Decomposition with clear boundaries limits a component's interactions to just its immediate neighbors—reducing complexity from O(n²) to O(n).
The Team Constraint: Parallel Work
Software is rarely built by individuals. Teams work concurrently on different parts of a system. Without clear boundaries between components:
Proper decomposition enables parallel development where teams can work independently on their components, confident that if they maintain their interfaces, integration will succeed.
A component, whether it's a class, a module, a service, or a subsystem, isn't just a random grouping of code. A well-defined component has specific properties that make it manageable, understandable, and maintainable.
The Five Essential Properties:
A Practical Example: Email Sender
Consider a component responsible for sending emails in an e-commerce system. Here's how the five properties apply:
| Property | Good Design | Poor Design |
|---|---|---|
| Single Responsibility | Sends emails via configured SMTP/API | Sends emails AND logs analytics AND manages templates AND handles bounce tracking |
| Well-Defined Interface | send(to, subject, body, options) with clear types | Dozens of methods with unclear relationships |
| Hidden Implementation | Could switch from SMTP to SendGrid without external changes | External code depends on SMTP connection details |
| Minimal Dependencies | Only needs config and maybe a template engine | Depends on user database, order system, logging framework, analytics... |
| Self-Containment | Can be unit tested with a mock SMTP server | Requires full system running to test |
Can you explain what this component does to a colleague in one breath? Can you write a unit test for it without mocking half the system? If the answers are no, the component isn't well-defined—it probably needs further decomposition.
Decomposition isn't random. There are established strategies for identifying natural boundaries in a system. Understanding these strategies and knowing when to apply each is a core LLD skill.
Strategy 1: Functional Decomposition
The most intuitive approach: break the system into pieces based on what they do. Each component handles a distinct function.
Example: E-commerce Order Processing
Strategy 2: Data-Centric Decomposition
Group components around the data they manage. This is particularly powerful when certain data naturally clusters together and has specific access patterns.
Example: Social Media Platform
Each component owns its data entirely—no other component directly accesses that data without going through the owning component.
Strategy 3: Layer-Based Decomposition
Organize components into horizontal layers where each layer has a specific abstraction level. Higher layers depend on lower layers, never the reverse.
Classic Three-Layer Architecture:
Extended Modern Layers:
Dependency Direction in Layered Architecture: ┌─────────────────────────────────┐│ Presentation Layer │ ─── Depends on ───┐└─────────────────────────────────┘ │ ▼┌─────────────────────────────────┐│ Business Logic Layer │ ─── Depends on ───┐└─────────────────────────────────┘ │ ▼┌─────────────────────────────────┐│ Data Access Layer │└─────────────────────────────────┘ Key Rule: Dependencies flow DOWNWARD only.The Presentation Layer NEVER depends on Data Access directly.The Business Layer NEVER depends on Presentation.Strategy 4: Feature/Vertical Slice Decomposition
Instead of horizontal layers, slice the system vertically by feature. Each slice contains all layers needed for that feature.
Example: E-commerce System Vertical Slices:
This approach excels when features are largely independent and teams own entire features.
No single strategy is universally best. Often, you'll combine strategies—layer-based decomposition at the macro level, functional decomposition within each layer, and data-centric boundaries around core entities. The key is consistency within a system and clear documentation of the approach.
One of the most challenging aspects of decomposition is determining the right size for components. Too large, and you haven't solved the complexity problem. Too small, and you've created a different complexity problem—too many moving pieces with too much coordination overhead.
Signs a Component Is Too Large:
The Goldilocks Zone
A well-sized component typically has:
These aren't rigid rules—they're starting points. A utility class might have 50 methods. A complex algorithm might fill 1,000 lines. But if you consistently exceed these ranges, examine why.
Excessive decomposition creates its own complexity. If you need a 'factory for factories' or 'managers managing managers,' you've gone too far. The goal is clarity, not abstraction for its own sake. Always ask: 'Is this decomposition making the code easier or harder to understand?'
Practical Heuristic: The 'Fitting in Your Head' Test
Ask yourself: Can a single developer, with moderate domain knowledge, fully understand this component in 15-30 minutes? This includes:
If the answer is no, the component is too complex and likely needs decomposition.
Not all decomposition points are equal. Some are arbitrary—you could draw the line here or there without much consequence. Others are natural boundaries where the system almost demands a separation. Learning to recognize natural boundaries is a hallmark of experienced designers.
Signals of Natural Boundaries:
The Domain-Driven Design Perspective
Domain-Driven Design (DDD) provides powerful tools for boundary identification:
Bounded Contexts — Parts of the system where a particular domain model applies. Within a bounded context, terms have precise meanings. Across boundaries, translation is needed.
Example: 'Customer' in Sales vs. Support
These are different models that happen to share a name. They belong in separate components with an explicit integration layer.
123456789101112131415161718192021222324252627282930313233343536373839
// WRONG: Mixing contexts creates confusioninterface Customer { // Sales properties leadScore: number; opportunities: Opportunity[]; lastPurchase: Date; // Support properties openTickets: Ticket[]; satisfactionScore: number; supportTier: string;} // RIGHT: Separate bounded contexts with explicit mapping// sales/customer.tsinterface SalesCustomer { id: string; leadScore: number; opportunities: Opportunity[]; lastPurchase: Date;} // support/customer.tsinterface SupportCustomer { id: string; openTickets: Ticket[]; satisfactionScore: number; supportTier: string;} // integration/customer-mapper.tsfunction mapSalesToSupport(salesCustomer: SalesCustomer): SupportCustomer { return { id: salesCustomer.id, openTickets: [], satisfactionScore: inferFromPurchaseHistory(salesCustomer), supportTier: determineTierFromPurchases(salesCustomer), };}Natural boundaries emerge from the domain and the way the system evolves. They're 'natural' in that fighting them causes friction—but they're not always obvious at first. Experienced designers develop intuition through practice and refactoring when initial boundaries prove wrong.
Understanding what not to do is as valuable as knowing best practices. Here are common decomposition anti-patterns that lead to systems worse than the monolith they were meant to replace.
Anti-Pattern 1: The God Class
A single class that knows everything, controls everything, and does everything. It accumulates responsibilities over time as developers add 'just one more method' for convenience.
Symptoms:
Anti-Pattern 2: Nanoservices
The opposite extreme—breaking every tiny piece into a separate component. The overhead of communication, deployment, and coordination exceeds the complexity of the actual logic.
Symptoms:
Anti-Pattern 3: The Distributed Monolith
The code is split into many components or services, but they're all tightly coupled. You can't deploy one without deploying all. Changes ripple everywhere. You have the complexity of distribution without the benefits.
Symptoms:
Anti-Pattern 4: Anemic Domain Model
Components that are just data holders (DTOs) with all logic in separate 'service' classes. This violates encapsulation and leads to logic scattered across the codebase.
Symptoms:
If you recognize these patterns in your codebase, don't despair—nearly every system has them somewhere. The key is recognizing them as technical debt and systematically refactoring toward better boundaries when the opportunity arises.
Let's apply these principles to a concrete example. Consider designing a Library Management System. We'll walk through the decomposition thought process.
Requirements Overview:
Step 1: Identify Core Entities
What are the main 'things' the system deals with?
Step 2: Group by Responsibility
Applying functional decomposition:
| Component | Responsibility | Entities Managed |
|---|---|---|
| CatalogManager | Book inventory, availability, search | Book |
| MemberManager | Member registration, profiles, limits | Member |
| BorrowingService | Checkout, returns, renewals | BorrowRecord |
| ReservationService | Reservations, queue management | Reservation |
| FineCalculator | Overdue detection, fine computation | Fine |
| NotificationService | Message sending, scheduling | Notification |
Step 3: Define Interfaces and Dependencies
Each component exposes a clean interface. Dependencies are explicit:
123456789101112131415161718192021222324252627282930313233343536
// Catalog Interfaceinterface ICatalogManager { addBook(book: BookDetails): Book; removeBook(bookId: string): void; searchBooks(criteria: SearchCriteria): Book[]; getAvailability(bookId: string): AvailabilityStatus;} // Borrowing Interfaceinterface IBorrowingService { checkout(memberId: string, bookId: string): BorrowRecord; returnBook(recordId: string): Fine | null; renew(recordId: string): BorrowRecord; getBorrowHistory(memberId: string): BorrowRecord[];} // Dependencies are injected, making them explicitclass BorrowingService implements IBorrowingService { constructor( private catalog: ICatalogManager, private members: IMemberManager, private fineCalculator: IFineCalculator, private notifications: INotificationService ) {} checkout(memberId: string, bookId: string): BorrowRecord { // Uses catalog to check availability // Uses members to verify borrowing limits // Could trigger a notification on success } returnBook(recordId: string): Fine | null { // Uses fineCalculator to compute any fine // Updates catalog availability }}Step 4: Validate the Decomposition
Ask the critical questions:
This decomposition passes our checks. In implementation, you might refine further—but the core structure is sound.
Decomposition is a skill that improves with practice. For every new system you design or codebase you join, ask: 'How is this decomposed? Why? What are the boundaries?' Train yourself to think in components, and it becomes instinctive.
We've covered the foundational skill of system decomposition. Let's consolidate the essential lessons:
What's Next:
Breaking a system into pieces is only the first step. Next, we need to ensure those pieces have clear responsibilities and boundaries—that each component owns exactly what it should, and no more. That's what we'll explore on the next page.
You now understand the principles and practices of breaking complex systems into manageable components. This is the foundation of the LLD mindset—think in pieces, design interfaces, respect boundaries. Next, we'll dive deeper into how to identify and assign responsibilities within those boundaries.