Decision Rules for Modular Software Design
Use these rules while preparing or reviewing a software design.
Interface Rules
- Prefer a small number of intention-revealing operations over many tiny methods.
- Name methods by caller intent, not internal workflow.
- Make the common case direct and hard to misuse.
- Keep rare cases isolated from the common path.
- Replace boolean flags with clearer domain concepts, policy objects, or separate operations when the flag changes behavior substantially.
- Avoid call-order requirements unless the sequence is the actual abstraction.
- Return stable, meaningful domain or application types instead of raw implementation artifacts.
- Expose only errors that callers can take meaningful action on.
- Document the interface contract before writing implementation code.
Information-Hiding Rules
Hide implementation knowledge unless callers legitimately control it.
Hide:
- Internal representations and schemas.
- SQL queries, ORM details, database transactions, and storage layout.
- HTTP, GraphQL, queue, RPC, or framework objects outside their boundary.
- Vendor DTOs, status codes, retry semantics, pagination tokens, and API quirks.
- Mapping between external and internal models.
- Validation order, lifecycle sequencing, caching, retries, timeouts, and concurrency behavior.
- Special-case normalization and fallback behavior.
- Constants or thresholds that are implementation details rather than product/domain policy.
Expose:
- The abstraction callers need.
- The smallest useful set of operations.
- Stable input and output types.
- Errors callers can handle.
- Configuration callers genuinely own.
Leakage Detection
A boundary leaks when callers must know facts that should belong inside the callee.
Check:
- Do API names mention table names, transport protocols, vendor concepts, or implementation mechanics?
- Do parameters include transactions, cursors, retry policies, mappers, raw rows, framework contexts, or internal state?
- Do return values expose storage rows, DTOs, status codes, exceptions, or framework/vendor objects?
- Must callers perform setup, cleanup, validation, retries, normalization, or method calls in a precise sequence?
- Are the same special cases handled by multiple callers?
- Would a storage, vendor, protocol, or framework change affect callers that should be insulated?
Pass-Through Detection
A pass-through method adds surface area without adding abstraction.
Suspicious:
class UserService:
def get_user(self, user_id):
return self.repository.get_user(user_id)
Acceptable only when the method changes abstraction by adding policy, translation, validation, orchestration, stability, security, or a domain concept.
A pass-through variable is data threaded through layers that do not use or own it. Move that state into the module that owns the operation, or collapse the boundary that merely forwards it.
Split or Merge Rules
Split modules when:
- They hide different volatile decisions.
- They change for different reasons.
- One is domain policy and the other is infrastructure.
- One concept can be used independently behind a clean interface.
- Combining them forces callers to depend on concepts they do not need.
Merge modules when:
- They constantly call each other.
- They share private knowledge.
- The split creates pass-through methods or variables.
- Callers must coordinate both modules in a precise order.
- The separation mirrors execution order rather than ownership of concepts.
- The public interfaces are almost as complex as the combined implementation.
Rule: split by hidden knowledge and axis of change; merge when separation exports coordination complexity.
Layering Rules
Each layer must provide a distinct abstraction.
Typical responsibilities:
- Transport layer: translate requests, responses, auth context, and protocol errors.
- Application layer: coordinate use cases and transaction-level workflows.
- Domain layer: enforce domain concepts, policies, invariants, and state transitions.
- Infrastructure layer: hide persistence, queues, external services, vendors, and framework details.
A layer is suspicious when it has the same method names, parameters, return shapes, and responsibilities as adjacent layers.
Do not pass database rows, HTTP requests, GraphQL contexts, ORM models, vendor DTOs, or framework objects into core domain logic unless those are genuinely part of the domain.
Pulling Complexity Downward
Pull complexity into a module when doing so removes duplicated caller logic, hides volatile decisions, centralizes invariants, or makes common usage obvious.
Do not pull complexity downward when the abstraction is speculative, surprising, too generic, only serves one simple caller, or turns the module into an unfocused god object.
Test: will the added internal complexity remove more complexity from callers and future changes than it adds to the module?
Designing Away Errors and Special Cases
Before exposing an error, flag, or special mode, ask:
- Can the interface make this state impossible?
- Can a default remove the branch?
- Can the module normalize inputs?
- Can validation happen once inside the module?
- Can a special case become an ordinary case?
- Can a domain concept replace a boolean or raw status code?
- Can retry/fallback behavior be hidden?
Expose a special case only when the caller has a legitimate decision to make.
Naming and Comments
Names and comments are part of the interface.
Use names that reveal domain intent, not mechanism. Comments should document contract, invariants, side effects, lifecycle constraints, defaults, error semantics, and non-obvious assumptions. Do not use comments to compensate for a vague abstraction or misleading name.