
Software Patterns
- 261 installs
- 63 repo stars
- Updated July 18, 2026
- bobmatnyc/claude-mpm-skills
Apply proven design patterns when structuring modules, APIs, state, and cross-cutting concerns so new features stay consistent and maintainable.
About
Software-patterns skill helps Claude recommend and apply established design and architectural patterns during implementation. It clarifies tradeoffs, naming, layering, and refactor targets so SaaS, API, and CLI codebases stay coherent, testable, and easier to extend without pattern sprawl.
- Names common GoF and architectural patterns
- Guides when to apply vs avoid patterns
- Maps patterns to module boundaries
- Reduces over-engineering and duplication
- Aligns implementations with team conventions
Software Patterns by the numbers
- 261 all-time installs (skills.sh)
- Ranked #298 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill software-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 261 |
|---|---|
| repo stars | ★ 63 |
| Last updated | July 18, 2026 |
| Repository | bobmatnyc/claude-mpm-skills ↗ |
What it does
Apply proven design patterns when structuring modules, APIs, state, and cross-cutting concerns so new features stay consistent and maintainable.
Files
Software Patterns Primer
Overview
Architectural patterns solve specific structural problems. This skill provides a decision framework for when to apply each pattern, not a catalog to memorize.
Core philosophy: Patterns solve problems. No problem? No pattern needed.
When to Use This Skill
Activate when:
- Designing a new system or major feature
- Adding external service integrations
- Code becomes difficult to test or modify
- Services start calling each other in circles
- Failures in one component cascade to others
- Business logic scatters across multiple locations
Pattern Hierarchy
Foundational (Apply by Default)
These patterns provide the structural foundation for maintainable systems. Apply unless you have specific reasons not to.
| Pattern | Problem Solved | Signal to Apply |
|---|---|---|
| Dependency Injection | Tight coupling, untestable code | Classes instantiate their own dependencies |
| Service-Oriented Architecture | Monolithic tangles, unclear boundaries | Business logic scattered, no clear ownership |
DI quick example — before and after:
# BEFORE: tight coupling, hard to test
class OrderService:
def __init__(self):
self.db = PostgresDatabase() # concrete dependency
self.mailer = SmtpMailer() # concrete dependency
# AFTER: dependencies injected, easily testable
class OrderService:
def __init__(self, db: Database, mailer: Mailer):
self.db = db
self.mailer = mailer
# In tests: OrderService(db=FakeDatabase(), mailer=FakeMailer())Situational (Apply When Triggered)
These patterns address specific problems. Don't apply preemptively.
| Pattern | Problem Solved | Signal to Apply |
|---|---|---|
| Repository | Data access coupling | Services know about database details |
| Domain Events | Circular dependencies, temporal coupling | Service A calls B calls C calls A |
| Anti-Corruption Layer | External system coupling | External API changes break your code |
| Circuit Breaker | Cascading failures | One slow service takes down everything |
→ Foundational Patterns Detail → Situational Patterns Detail
Quick Decision Tree
Is code hard to test?
├─ Yes → Apply Dependency Injection
└─ No → Continue
Is business logic scattered?
├─ Yes → Apply Service-Oriented Architecture
└─ No → Continue
Do services know database details?
├─ Yes → Apply Repository Pattern
└─ No → Continue
Do services call each other in cycles?
├─ Yes → Apply Domain Events
└─ No → Continue
Does external API change break your code?
├─ Yes → Apply Anti-Corruption Layer
└─ No → Continue
Does one slow service break everything?
├─ Yes → Apply Circuit Breaker
└─ No → Current patterns sufficient→ Complete Decision Trees
Implementation Priority
When starting a new system:
1. First: Establish DI container/pattern 2. Second: Define service boundaries (SOA) 3. Third: Add Repository for data access 4. Then: Layer situational patterns as problems emerge
When refactoring existing system:
1. First: Identify the specific pain point 2. Second: Apply the minimal pattern that solves it 3. Third: Validate improvement before adding more
Navigation
Pattern Details
- [Foundational Patterns](references/foundational-patterns.md): DI and SOA implementation guides, when to deviate
- [Situational Patterns](references/situational-patterns.md): Repository, Domain Events, ACL, Circuit Breaker details
Decision Support
- [Decision Trees](references/decision-trees.md): Complete flowcharts for pattern selection
- [Anti-Patterns](references/anti-patterns.md): Common misapplications and how to recognize them
- [Code-Smell Signals](references/code-smell-signals.md): Low-level code smells (large switches, nested loops, parameter reassignment, high complexity/coupling) mapped to the architectural problems they signal and the pattern that fixes each — derived from CAST Highlight
_multiquality indicators (https://doc.casthighlight.com/)
Implementation
- [Examples](references/examples.md): Language-agnostic pseudocode for each pattern combination
Red Flags - STOP
STOP when:
- "Let me add all these patterns upfront" → Apply only what solves current problems
- "This pattern is best practice" → Best practice for what problem?
- "We might need this later" → YAGNI - add when needed
- "Service Locator is simpler" → Hidden dependencies cause testing pain
- "I'll just call this service directly" → Consider if events would decouple better
- "External API is stable, no need for ACL" → APIs always change eventually
ALL of these mean: STOP. Identify the specific problem first.
Integration with Other Skills
- test-driven-development: DI enables testability; TDD validates pattern application
- systematic-debugging: Clear boundaries (SOA) simplify debugging
- root-cause-tracing: Well-structured services have clearer call chains
Pattern Combinations
Common effective combinations:
| Scenario | Patterns |
|---|---|
| New microservice | DI + SOA + Repository |
| External API integration | DI + ACL + Circuit Breaker |
| Event-driven system | DI + SOA + Domain Events |
| Data-heavy application | DI + SOA + Repository + Unit of Work |
---
Remember: Patterns exist to solve problems. Start with the problem, not the pattern.
{
"name": "software-patterns",
"version": "1.1.0",
"category": "universal",
"toolchain": null,
"framework": null,
"tags": [
"architecture",
"patterns",
"design",
"dependency-injection",
"service-oriented",
"code-smells",
"refactoring",
"complexity"
],
"entry_point_tokens": 75,
"full_tokens": 18000,
"author": "masa",
"license": "MIT",
"requires": [],
"updated": "2026-06-15",
"source_path": "architecture/software-patterns/SKILL.md",
"created": "2025-11-30",
"modified": "2025-11-30",
"maintainer": "masa",
"attribution_required": false,
"repository": "https://github.com/bobmatnyc/claude-mpm-skills"
}
Anti-Patterns
Common misapplications of architectural patterns and how to recognize them.
Dependency Injection Anti-Patterns
Service Locator Masquerading as DI
The problem: Using a global registry to look up dependencies at runtime instead of injecting them.
// ANTI-PATTERN: Service Locator
class UserService {
createUser(data) {
notifier = ServiceLocator.get("Notifier") // Hidden dependency
repo = ServiceLocator.get("UserRepository")
// ...
}
}
// CORRECT: Dependency Injection
class UserService {
constructor(notifier, repo) { // Explicit dependencies
this.notifier = notifier
this.repo = repo
}
}Why it's harmful:
- Dependencies hidden - can't tell what UserService needs without reading implementation
- Testing requires global state manipulation
- Order-dependent initialization
- Harder to reason about
Signs you're doing this:
- Calling
Container.resolve()orServiceLocator.get()inside methods - Tests need to configure global registries
- "Which services does X depend on?" requires code inspection
---
Constructor Over-Injection
The problem: A class requires so many dependencies it signals design issues.
// ANTI-PATTERN: Too many dependencies
class OrderService {
constructor(
userRepo, orderRepo, productRepo, inventoryRepo,
paymentService, shippingService, notificationService,
taxCalculator, discountEngine, fraudDetector,
logger, metrics, cache
) { ... }
}Why it's harmful:
- Class doing too much (violates Single Responsibility)
- Difficult to understand
- Testing requires many mocks
- Probably has feature envy
How to fix:
- Extract cohesive groups into new services
- Consider if some are cross-cutting concerns (logging, metrics) that don't need injection
- Question if class should exist at all
// BETTER: Smaller, focused services
class OrderProcessor {
constructor(orderRepo, inventoryService, paymentGateway) { ... }
}
class OrderNotifier {
constructor(notificationService, templateEngine) { ... }
}---
Injecting the Container
The problem: Passing the DI container itself as a dependency.
// ANTI-PATTERN: Container as dependency
class UserService {
constructor(container) {
this.container = container
}
createUser(data) {
notifier = this.container.resolve("Notifier") // Still Service Locator!
}
}Why it's harmful:
- Hides actual dependencies
- Makes class depend on container implementation
- Same problems as Service Locator
Correct approach: Resolve at composition root, inject concrete dependencies.
---
Service-Oriented Architecture Anti-Patterns
Anemic Services
The problem: Services that are just data containers with no behavior.
// ANTI-PATTERN: Anemic service
class UserService {
getUser(id) { return repo.findById(id) }
saveUser(user) { return repo.save(user) }
deleteUser(id) { return repo.delete(id) }
}
// Business logic elsewhere
class UserController {
register(data) {
if (!isValidEmail(data.email)) throw Error() // Logic leaked out
if (data.password.length < 8) throw Error()
user = new User(data)
userService.saveUser(user)
sendWelcomeEmail(user) // More leaked logic
}
}Why it's harmful:
- Business logic scattered
- Service provides no value over repository
- Controller/caller becomes bloated
Correct approach: Services encapsulate business logic.
// BETTER: Rich service
class UserService {
register(data) {
this.validateRegistration(data) // Validation here
user = User.create(data)
this.repo.save(user)
this.eventBus.publish(new UserRegistered(user)) // Side effects here
return user
}
}---
Chatty Services
The problem: Services making many fine-grained calls to each other.
// ANTI-PATTERN: Chatty communication
class OrderService {
createOrder(customerId, items) {
customer = this.customerService.getCustomer(customerId)
this.customerService.validateCustomer(customer)
address = this.customerService.getDefaultAddress(customerId)
this.customerService.validateAddress(address)
credit = this.customerService.getCreditLimit(customerId)
// ...5 more calls...
}
}Why it's harmful:
- Network overhead (if distributed)
- Tight coupling through multiple calls
- Harder to maintain consistency
- Sign of wrong service boundaries
How to fix:
- Consider merging services
- Create coarse-grained operations
- Use data transfer objects
// BETTER: Coarse-grained call
class OrderService {
createOrder(customerId, items) {
customerContext = this.customerService.getOrderContext(customerId)
// Single call returns everything needed
}
}---
Distributed Monolith
The problem: Services that must be deployed and changed together.
Signs:
- Changing Service A requires changing Service B
- Services share database tables
- Circular dependencies between services
- Deployment order matters
Why it's harmful:
- Worst of both worlds: distribution complexity without independence
- Can't scale or deploy independently
- Adds network failures without adding flexibility
How to fix:
- Draw actual dependency graph
- Services should be independently deployable
- If they always change together, merge them
---
Repository Anti-Patterns
Generic Repository
The problem: One-size-fits-all repository interface that handles any entity.
// ANTI-PATTERN: Generic repository
interface Repository<T> {
findById(id) → T
findAll() → T[]
save(entity: T)
delete(id)
query(specification) → T[]
}
class UserService {
constructor(repo: Repository<User>) { ... }
}Why it's harmful:
- Doesn't expose domain-meaningful operations
- Leaks query logic to callers
- False abstraction - entities have different access patterns
findAll()on a table with millions of rows?
Correct approach: Entity-specific repositories with meaningful methods.
// BETTER: Domain-specific repository
interface UserRepository {
findById(id) → User | null
findByEmail(email) → User | null
findActiveUsers(since: Date) → User[]
// No generic query() method
}---
Repository Returning Primitives
The problem: Repository methods returning raw data instead of domain objects.
// ANTI-PATTERN: Returns primitives
interface UserRepository {
findById(id) → { id: string, email: string, created: string }
}
// Caller must construct domain object
class UserService {
getUser(id) {
data = this.repo.findById(id)
return new User(data.id, data.email, new Date(data.created)) // Mapping here
}
}Why it's harmful:
- Mapping logic scattered
- Repository knows about database, caller knows about mapping
- Inconsistent domain objects possible
Correct approach: Repository owns the mapping.
// BETTER: Returns domain objects
interface UserRepository {
findById(id) → User | null // Already mapped
}---
Domain Events Anti-Patterns
Events as Remote Procedure Calls
The problem: Using events to trigger actions and waiting for response.
// ANTI-PATTERN: Event as RPC
class OrderService {
createOrder(data) {
order = new Order(data)
event = new ValidateOrderRequested(order)
this.eventBus.publish(event)
// Waiting for response event - this is RPC!
validationResult = await this.eventBus.waitFor(
OrderValidated,
(e) => e.orderId == order.id
)
if (!validationResult.isValid) throw Error()
}
}Why it's harmful:
- Adds complexity without decoupling benefit
- Still synchronous, just obfuscated
- Harder to debug than direct call
- Timeout handling complexity
Correct approach: Use direct call for synchronous needs, events for async reactions.
// BETTER: Direct call for synchronous validation
class OrderService {
createOrder(data) {
this.validator.validate(data) // Direct, synchronous
order = new Order(data)
this.repo.save(order)
this.eventBus.publish(new OrderCreated(order)) // Async reactions
}
}---
Bidirectional Events
The problem: Services publishing events that trigger events back to themselves.
// ANTI-PATTERN: Event ping-pong
OrderService publishes OrderCreated
→ InventoryService subscribes, publishes InventoryReserved
→ OrderService subscribes, publishes OrderConfirmed
→ ShippingService subscribes, publishes ShipmentScheduled
→ OrderService subscribes, updates order...Why it's harmful:
- Hard to trace flow
- Easy to create infinite loops
- Debugging nightmare
- Often hides that services are too coupled
How to fix:
- Events flow one direction (downstream)
- If bidirectional communication needed, reconsider boundaries
- Use choreography for simple flows, orchestration for complex
---
Circuit Breaker Anti-Patterns
Circuit Breaker Everywhere
The problem: Adding circuit breakers to every call, including local operations.
// ANTI-PATTERN: Circuit breaker on local call
userCircuit = new CircuitBreaker(() => this.repo.findById(id))
// Even worse
validationCircuit = new CircuitBreaker(() => validateEmail(email))Why it's harmful:
- Overhead without benefit for local calls
- Masks actual problems (why would local repo fail repeatedly?)
- False confidence in resilience
Correct approach: Circuit breakers for network calls to external services.
---
Ignoring Circuit State
The problem: Catching circuit open errors and retrying anyway.
// ANTI-PATTERN: Defeating the circuit
try {
result = paymentCircuit.execute(amount)
} catch (CircuitOpenError) {
// Circuit open, let me just try anyway...
result = paymentService.charge(amount) // Defeats the purpose!
}Why it's harmful:
- Circuit breaker exists to prevent overload
- Bypassing it continues hammering failing service
- Wasted resources
Correct approach: Handle circuit open gracefully.
// BETTER: Graceful degradation
try {
result = paymentCircuit.execute(amount)
} catch (CircuitOpenError) {
// Queue for later, show user-friendly message
this.retryQueue.enqueue(new DeferredPayment(amount, order))
return PaymentResult.deferred("We'll process your payment shortly")
}---
General Anti-Patterns
Pattern Cargo Culting
The problem: Applying patterns because "best practice" without understanding the problem they solve.
Signs:
- "We always use Repository pattern"
- "DDD says we need aggregates"
- Patterns applied to trivial problems
- Architecture diagram looks impressive but code is simple CRUD
How to fix: Start with the problem. What specific issue does this pattern solve here?
---
Premature Abstraction
The problem: Creating abstractions before understanding the concrete cases.
// ANTI-PATTERN: Abstraction with one implementation
interface MessageSender { send(message) }
class EmailMessageSender implements MessageSender { ... }
// No other senders exist or are plannedWhy it's harmful:
- Abstractions are guesses about future variation
- Wrong abstraction harder to fix than no abstraction
- Adds indirection without flexibility
Rule of thumb: Wait for the third concrete case before abstracting, or until you have clear evidence of needed variation.
---
Leaky Abstractions
The problem: Abstractions that don't fully hide what they abstract.
// ANTI-PATTERN: Leaky repository
interface UserRepository {
findById(id) → User
executeSql(query: string) → any[] // Leaks SQL!
getConnection() → DatabaseConnection // Leaks database!
}Why it's harmful:
- Callers can bypass abstraction
- Implementation details leak out
- Abstraction provides false security
Correct approach: If abstraction is needed, make it complete.
Code-Smell Signals That Should Trigger Architectural Review
This appendix maps low-level code smells — the kind a linter or quality scanner counts — to the architectural problems they usually indicate. A single occurrence is a code smell; a high density of these signals across a module is a signal that a structural pattern is missing or being violated. Use this as a bridge between "the scanner flagged N violations" and "which pattern should we apply."
Source note: The detection signals below are derived from CAST Highlight's
language-agnostic (_multi) code-quality indicators (https://doc.casthighlight.com/),which span the Changeability, Efficiency, and Transferability families. CAST in turn
references established complexity literature (cyclomatic complexity, Fowler's
Refactoring code smells). The architectural interpretations and remedies here are our
synthesis. Counting thresholds quoted as ranges are CAST's calibration, presented as
attributed reference, not universal standards.
---
1. Large switch / long if-else chains → missing polymorphism
Signal: A switch (or if/elif) statement with many branches keying off a type tag or enum, often duplicated in several places that switch on the same tag. (CAST flags files whose average cases-per-switch exceeds a low single-digit threshold.)
What it usually means: Two sets of data are being mapped imperatively where a real map structure or polymorphic dispatch belongs. When the same multi-branch switch recurs, adding a new case means editing every copy — an Open/Closed Principle violation.
Architectural remedy: Replace the type-tag switch with polymorphism (a Strategy or a registry/map of handlers keyed by the tag). See foundational-patterns.md (DI) and situational-patterns.md (Strategy). The cross-cutting variants often want a Factory or table-driven dispatch instead.
*When not to refactor: A single, localized switch over a closed, stable set (e.g., parsing a wire-format byte) is fine. The signal is recurrence and growth*, not the construct itself.
---
2. Deeply nested loops → data-modelling or algorithmic problem
Signal: A loop immediately nested inside another loop (CAST counts each nesting). Two-dimensional nesting implies O(n²) work; deeper nesting compounds it.
What it usually means: The data is being joined or grouped in application code that a better data structure (a hash map for O(1) lookup) or a query (a database join) should do once. It also frequently hides an N+1 query when the inner loop issues I/O.
Architectural remedy: Push the join into the data layer (see the SQL anti-patterns in the sqlalchemy skill), or pre-index one collection into a map before the outer loop. When the nesting is genuine combinatorics, isolate it behind a well-named service boundary so its cost is explicit and cacheable.
---
3. Parameters reassigned inside a routine → missing immutability
Signal: A function/method body reassigns its own parameters (=, +=, ++, etc.).
What it usually means: Mutable in/out parameters make data flow hard to follow and break referential transparency — a small-scale symptom of the broader problem that the module lacks clear immutable value objects and instead threads mutable state through calls.
Architectural remedy: Introduce a local variable for the transformed value and keep parameters read-only; at the design level, prefer immutable value objects / DTOs crossing boundaries. This is foundational to the Anti-Corruption Layer and Domain Events patterns, both of which assume messages are immutable.
---
4. High structural / cyclomatic complexity → unit doing too much (God object / SRP)
Signal: A method or class with high cyclomatic complexity, many responsibilities, many methods/fields, or a very long body. CAST treats rising complexity as the "arthritis of software" and correlates it with defect density.
What it usually means: A God object / too-many-responsibilities violation of the Single Responsibility Principle. High coupling (the unit reaches into many others) and low cohesion (its methods don't relate) typically travel together.
Architectural remedy: Decompose along responsibilities into focused services (SOA / service decomposition), inject collaborators (DI), and put a Repository between domain logic and persistence so the God object stops owning data access. See decision-trees.md for choosing the decomposition.
---
5. High coupling / many cross-module references → missing boundary
Signal: A module that imports or calls into a large fan-out of other modules, or many modules that all depend on one central type (a "hub").
What it usually means: Absent or leaky boundaries — changes ripple because there is no seam. This is the structural precondition that Anti-Corruption Layer, Domain Events, and Circuit Breaker patterns exist to address.
Architectural remedy: Introduce an explicit boundary (interface + DI) so the hub depends on abstractions, not concretions; route cross-context communication through events or a translation layer rather than direct calls.
---
How to use these signals
1. Run a quality scanner (or linter) and look at densities, not single hits. 2. Map the dominant smell to its row above to get a candidate architectural cause. 3. Confirm before refactoring — a smell is a hypothesis. Read the code (see the systematic-debugging discipline) before applying a pattern. 4. Apply the pattern from foundational-patterns.md / situational-patterns.md and re-measure; the smell density should drop.
For the project-wide, severity-tagged efficiency/transferability checklist that counts many of these signals, see the code-review-standards skill.
Decision Trees
Detailed flowcharts for pattern selection based on observed problems.
Master Decision Tree
START: What problem are you experiencing?
│
├─► "Code is hard to test"
│ └─► Go to: Testability Decision Tree
│
├─► "Business logic is scattered"
│ └─► Go to: Organization Decision Tree
│
├─► "External systems cause problems"
│ └─► Go to: External Integration Decision Tree
│
├─► "Services are too coupled"
│ └─► Go to: Coupling Decision Tree
│
├─► "Data operations are inconsistent"
│ └─► Go to: Data Management Decision Tree
│
└─► "No specific problem"
└─► STOP. Don't add patterns without problems.---
Testability Decision Tree
Code is hard to test
│
├─► Are classes creating their own dependencies?
│ │
│ ├─► Yes
│ │ └─► Apply: DEPENDENCY INJECTION
│ │ - Pass dependencies via constructor
│ │ - Define interfaces for mockable boundaries
│ │
│ └─► No
│ │
│ ├─► Are you testing through UI/API when unit test would work?
│ │ └─► Not a pattern problem - use correct test level
│ │
│ ├─► Are database calls mixed with business logic?
│ │ └─► Apply: REPOSITORY PATTERN
│ │ - Abstract data access
│ │ - Mock repository in tests
│ │
│ └─► Are external APIs called directly?
│ └─► Apply: ANTI-CORRUPTION LAYER
│ - Wrap external calls
│ - Mock wrapper in testsTestability Pattern Selection Summary
| Symptom | Pattern | Why |
|---|---|---|
new Dependency() in class | DI | Can't substitute test doubles |
| SQL in business logic | Repository | Can't test without database |
| Direct external API calls | ACL | Can't test without external service |
| Global state dependencies | DI + refactor | Hidden dependencies untestable |
---
Organization Decision Tree
Business logic is scattered
│
├─► Is the same validation/rule in multiple places?
│ │
│ ├─► Yes, and rules are complex
│ │ └─► Apply: SPECIFICATION PATTERN
│ │ - Encapsulate rules as objects
│ │ - Compose and reuse
│ │
│ └─► Yes, but rules are simple
│ └─► Extract to shared function first
│ - Only add Specification if rules grow complex
│
├─► Is it unclear which team/component owns what logic?
│ │
│ └─► Apply: SERVICE-ORIENTED ARCHITECTURE
│ - Define service boundaries
│ - Each service owns its domain
│ - Clear interfaces between services
│
├─► Does business code know about database structure?
│ │
│ └─► Apply: REPOSITORY PATTERN
│ - Services speak domain language
│ - Repository handles persistence
│
└─► Is feature code spread across layers (controller → service → repo)?
│
└─► This is often correct! Layers serve different purposes.
- Question if layers are needed
- Don't merge without causeOrganization Pattern Selection Summary
| Symptom | Pattern | Why |
|---|---|---|
| Duplicated complex rules | Specification | Single source of truth, testable |
| "Who owns this code?" | SOA | Clear boundaries and ownership |
| SQL leaking to business layer | Repository | Abstraction isolates concerns |
| Identical code in controller and service | Review architecture | May indicate wrong boundaries |
---
External Integration Decision Tree
External systems cause problems
│
├─► Do API changes from vendor break your code?
│ │
│ └─► Apply: ANTI-CORRUPTION LAYER
│ - Define your interface
│ - Translate at boundary
│ - Internal code uses your types
│
├─► Does external service unreliability affect your users?
│ │
│ ├─► Failures cascade (one service down = everything down)
│ │ └─► Apply: CIRCUIT BREAKER
│ │ - Fail fast when service unhealthy
│ │ - Degrade gracefully
│ │
│ ├─► Transient failures (occasional timeouts, retries help)
│ │ └─► Apply: RETRY WITH BACKOFF
│ │ - Exponential backoff
│ │ - Maximum retry count
│ │ - Combine with circuit breaker for persistent failures
│ │
│ └─► Slow responses hurt performance
│ └─► Apply: TIMEOUT + CIRCUIT BREAKER
│ - Timeout individual calls
│ - Circuit breaker for persistent slowness
│
└─► Is external terminology leaking into your domain?
│
└─► Apply: ANTI-CORRUPTION LAYER
- Translate at boundary
- Your code uses your languageExternal Integration Pattern Combinations
| Scenario | Patterns | Notes |
|---|---|---|
| Unreliable third-party API | ACL + Circuit Breaker + Retry | Full protection |
| Stable but foreign terminology | ACL only | Translation without resilience |
| Internal microservice | Circuit Breaker if unreliable | ACL usually overkill |
| Critical payment provider | ACL + Circuit Breaker + Fallback | Graceful degradation |
---
Coupling Decision Tree
Services are too coupled
│
├─► Do services call each other in cycles?
│ │
│ (A → B → C → A or similar)
│ │
│ └─► Apply: DOMAIN EVENTS
│ - Break cycle with publish/subscribe
│ - Services react to events, don't call directly
│
├─► Must all services be available for any operation?
│ │
│ └─► Evaluate each dependency:
│ ├─► Required for correctness? → Keep synchronous
│ └─► Nice-to-have/eventual? → Apply: DOMAIN EVENTS
│
├─► Does adding new feature require changing multiple services?
│ │
│ ├─► Services share data inappropriately?
│ │ └─► Apply: SOA with clear data ownership
│ │
│ └─► Services have wrong boundaries?
│ └─► Refactor boundaries
│ - May need to merge services
│ - May need to split differently
│
└─► Do services share a database?
│
└─► Often root cause of coupling
- Each service should own its data
- If sharing required, explicit shared serviceCoupling Indicators and Solutions
| Indicator | Likely Problem | Solution |
|---|---|---|
| Circular calls between services | Wrong boundaries or missing events | Domain Events or merge |
| Deploy A requires deploy B | Shared assumptions or contracts | Version interfaces, decouple |
| Shared database tables | Data ownership unclear | Service per bounded context |
| "Cannot test without X running" | Temporal coupling | Async events or better mocking |
---
Data Management Decision Tree
Data operations are inconsistent
│
├─► Multiple entities must update atomically?
│ │
│ (Transfer between accounts, order with items, etc.)
│ │
│ └─► Apply: UNIT OF WORK
│ - Track changes
│ - Commit as single transaction
│
├─► Same query written in multiple places?
│ │
│ └─► Apply: REPOSITORY PATTERN
│ - Encapsulate queries
│ - Single source for data access
│
├─► Business logic mixed with persistence logic?
│ │
│ └─► Apply: REPOSITORY + SERVICE
│ - Repository handles data
│ - Service handles business rules
│
└─► Complex filtering/selection logic?
│
└─► Apply: SPECIFICATION PATTERN
- Encapsulate criteria
- Compose queries
- Repository accepts specifications---
Quick Reference: Problem → Pattern
| Problem | First Pattern | Additional Patterns |
|---|---|---|
| Hard to test | DI | Repository, ACL |
| Scattered logic | SOA | Repository, Specification |
| API changes break code | ACL | Circuit Breaker |
| Cascading failures | Circuit Breaker | Retry, Timeout |
| Circular dependencies | Domain Events | SOA refactor |
| Data inconsistency | Unit of Work | Repository |
| Complex business rules | Specification | Domain Events |
---
Decision Checklist
Before applying any pattern, answer:
1. What specific problem am I solving?
- If you can't name it, don't apply pattern
2. Will this pattern solve that problem?
- Understand the pattern's purpose
- Match to your problem
3. What is the cost of this pattern?
- Complexity added
- Learning curve
- Maintenance burden
4. Is there a simpler solution?
- Sometimes refactoring beats patterns
- Sometimes the "problem" is acceptable
5. Can I validate the pattern helped?
- Before/after comparison
- Measurable improvement
Pattern Examples
Language-agnostic pseudocode demonstrating pattern implementations and combinations.
Example 1: E-Commerce Order System
Demonstrates: DI, SOA, Repository, Domain Events, Circuit Breaker
Problem Context
- Orders require inventory check, payment processing, notification
- Payment provider occasionally has outages
- Multiple teams own different capabilities
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Composition Root │
│ (Wires all dependencies at startup) │
└─────────────────────────────────────────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────┐ ┌───────────┐ ┌────────────┐ ┌───────────┐
│ Order │ │ Inventory │ │ Payment │ │Notification│
│ Service │ │ Service │ │ Service │ │ Service │
└────┬─────┘ └─────┬─────┘ └──────┬─────┘ └─────┬─────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────┐ ┌───────────┐ ┌────────────┐ ┌───────────┐
│ Order │ │ Inventory │ │ Payment │ │ Email │
│Repository│ │ Repository│ │ Gateway │ │ Gateway │
└──────────┘ └───────────┘ │ (with ACL) │ └───────────┘
└────────────┘
│
┌─────┴─────┐
│ Circuit │
│ Breaker │
└───────────┘Implementation
// ═══════════════════════════════════════════════════════════
// REPOSITORIES (Data Access Abstraction)
// ═══════════════════════════════════════════════════════════
interface OrderRepository {
save(order: Order) → Order
findById(id: OrderId) → Order | null
findByCustomer(customerId: CustomerId) → Order[]
}
interface InventoryRepository {
findByProduct(productId: ProductId) → InventoryItem | null
reserve(productId: ProductId, quantity: int) → ReservationId
release(reservationId: ReservationId)
}
// ═══════════════════════════════════════════════════════════
// ANTI-CORRUPTION LAYER (External Payment Provider)
// ═══════════════════════════════════════════════════════════
// Your interface (your language)
interface PaymentGateway {
charge(amount: Money, method: PaymentMethod) → PaymentResult
refund(paymentId: PaymentId, amount: Money) → RefundResult
}
// ACL Implementation
class StripePaymentGateway implements PaymentGateway {
constructor(stripeClient, circuitBreaker) {
this.stripe = stripeClient
this.circuit = circuitBreaker
}
charge(amount, method) {
// Circuit breaker wraps external call
return this.circuit.execute(() => {
// Translate your types to Stripe types
stripeResult = this.stripe.createCharge({
amount: amount.toCents(),
currency: amount.currency.code,
source: this.mapPaymentMethod(method)
})
// Translate Stripe response to your types
return new PaymentResult(
PaymentId(stripeResult.id),
this.mapStatus(stripeResult.status)
)
})
}
private mapStatus(stripeStatus) {
return match(stripeStatus) {
"succeeded" => PaymentStatus.Completed
"pending" => PaymentStatus.Processing
"failed" => PaymentStatus.Failed
}
}
}
// ═══════════════════════════════════════════════════════════
// DOMAIN EVENTS
// ═══════════════════════════════════════════════════════════
class OrderCreated {
orderId: OrderId
customerId: CustomerId
items: OrderItem[]
timestamp: DateTime
}
class PaymentCompleted {
orderId: OrderId
paymentId: PaymentId
amount: Money
timestamp: DateTime
}
class OrderShipped {
orderId: OrderId
trackingNumber: string
timestamp: DateTime
}
// ═══════════════════════════════════════════════════════════
// SERVICES (Business Logic)
// ═══════════════════════════════════════════════════════════
class OrderService {
constructor(
orderRepo: OrderRepository,
inventoryService: InventoryService,
paymentGateway: PaymentGateway,
eventBus: EventBus
) {
this.orderRepo = orderRepo
this.inventory = inventoryService
this.payment = paymentGateway
this.events = eventBus
}
createOrder(customerId, items, paymentMethod) {
// Reserve inventory
reservations = []
for (item of items) {
reservation = this.inventory.reserve(item.productId, item.quantity)
reservations.push(reservation)
}
// Calculate total
total = this.calculateTotal(items)
// Process payment
paymentResult = this.payment.charge(total, paymentMethod)
if (paymentResult.status != PaymentStatus.Completed) {
// Release reservations on payment failure
for (reservation of reservations) {
this.inventory.release(reservation)
}
throw new PaymentFailedError(paymentResult)
}
// Create order
order = Order.create(customerId, items, paymentResult.paymentId)
this.orderRepo.save(order)
// Publish event - don't know or care who listens
this.events.publish(new OrderCreated(
order.id,
customerId,
items,
DateTime.now()
))
return order
}
}
// Notification service subscribes to events
class NotificationService {
constructor(emailGateway, templateEngine) {
this.email = emailGateway
this.templates = templateEngine
}
// Called by event bus, not by OrderService
onOrderCreated(event: OrderCreated) {
customer = this.customerRepo.findById(event.customerId)
body = this.templates.render("order-confirmation", {
orderId: event.orderId,
items: event.items
})
this.email.send(customer.email, "Order Confirmed", body)
}
onOrderShipped(event: OrderShipped) {
// Different reaction to different event
// ...
}
}
// ═══════════════════════════════════════════════════════════
// COMPOSITION ROOT (Wiring)
// ═══════════════════════════════════════════════════════════
function createApplication(config) {
// Infrastructure
database = createDatabaseConnection(config.database)
stripeClient = new StripeClient(config.stripe.apiKey)
smtpClient = new SmtpClient(config.smtp)
eventBus = new InProcessEventBus()
// Circuit breaker for unreliable external service
paymentCircuit = new CircuitBreaker({
failureThreshold: 5,
resetTimeout: 30000
})
// Repositories
orderRepo = new PostgresOrderRepository(database)
inventoryRepo = new PostgresInventoryRepository(database)
customerRepo = new PostgresCustomerRepository(database)
// Gateways (with ACL)
paymentGateway = new StripePaymentGateway(stripeClient, paymentCircuit)
emailGateway = new SmtpEmailGateway(smtpClient)
// Services
inventoryService = new InventoryService(inventoryRepo)
notificationService = new NotificationService(emailGateway, templateEngine)
orderService = new OrderService(
orderRepo,
inventoryService,
paymentGateway,
eventBus
)
// Subscribe event handlers
eventBus.subscribe(OrderCreated, notificationService.onOrderCreated)
eventBus.subscribe(OrderShipped, notificationService.onOrderShipped)
return { orderService, inventoryService }
}---
Example 2: User Authentication System
Demonstrates: DI, Repository, Specification Pattern
Problem Context
- Complex eligibility rules for premium features
- Multiple storage backends (some users in legacy system)
- Rules change frequently
Implementation
// ═══════════════════════════════════════════════════════════
// SPECIFICATION PATTERN (Complex Business Rules)
// ═══════════════════════════════════════════════════════════
interface Specification<T> {
isSatisfiedBy(candidate: T) → boolean
}
class And<T> implements Specification<T> {
constructor(left: Specification<T>, right: Specification<T>) {
this.left = left
this.right = right
}
isSatisfiedBy(candidate) {
return this.left.isSatisfiedBy(candidate)
&& this.right.isSatisfiedBy(candidate)
}
}
class Or<T> implements Specification<T> {
constructor(left: Specification<T>, right: Specification<T>) {
this.left = left
this.right = right
}
isSatisfiedBy(candidate) {
return this.left.isSatisfiedBy(candidate)
|| this.right.isSatisfiedBy(candidate)
}
}
class Not<T> implements Specification<T> {
constructor(spec: Specification<T>) {
this.spec = spec
}
isSatisfiedBy(candidate) {
return !this.spec.isSatisfiedBy(candidate)
}
}
// Concrete user specifications
class HasActiveSubscription implements Specification<User> {
isSatisfiedBy(user) {
return user.subscription?.status == "active"
}
}
class AccountAgeGreaterThan implements Specification<User> {
constructor(days: int) {
this.days = days
}
isSatisfiedBy(user) {
return daysBetween(user.createdAt, now()) > this.days
}
}
class HasVerifiedEmail implements Specification<User> {
isSatisfiedBy(user) {
return user.emailVerifiedAt != null
}
}
class IsInBetaProgram implements Specification<User> {
isSatisfiedBy(user) {
return user.betaProgramEnrolledAt != null
}
}
class HasCompletedOnboarding implements Specification<User> {
isSatisfiedBy(user) {
return user.onboardingCompletedAt != null
}
}
// ═══════════════════════════════════════════════════════════
// COMPOSED ELIGIBILITY RULES
// ═══════════════════════════════════════════════════════════
class EligibilityRules {
// Premium features: active subscription + verified email + 30 days old
premiumFeatures = and(
new HasActiveSubscription(),
and(
new HasVerifiedEmail(),
new AccountAgeGreaterThan(30)
)
)
// Beta features: in beta program OR (premium + completed onboarding)
betaFeatures = or(
new IsInBetaProgram(),
and(
this.premiumFeatures,
new HasCompletedOnboarding()
)
)
// API access: verified email + 7 days old
apiAccess = and(
new HasVerifiedEmail(),
new AccountAgeGreaterThan(7)
)
}
// ═══════════════════════════════════════════════════════════
// SERVICE USING SPECIFICATIONS
// ═══════════════════════════════════════════════════════════
class FeatureAccessService {
constructor(userRepo: UserRepository, rules: EligibilityRules) {
this.userRepo = userRepo
this.rules = rules
}
canAccessPremiumFeatures(userId: UserId) → boolean {
user = this.userRepo.findById(userId)
if (!user) return false
return this.rules.premiumFeatures.isSatisfiedBy(user)
}
canAccessBetaFeatures(userId: UserId) → boolean {
user = this.userRepo.findById(userId)
if (!user) return false
return this.rules.betaFeatures.isSatisfiedBy(user)
}
getAccessibleFeatures(userId: UserId) → FeatureSet {
user = this.userRepo.findById(userId)
if (!user) return FeatureSet.empty()
features = FeatureSet.basic()
if (this.rules.premiumFeatures.isSatisfiedBy(user)) {
features = features.add(FeatureSet.premium())
}
if (this.rules.betaFeatures.isSatisfiedBy(user)) {
features = features.add(FeatureSet.beta())
}
if (this.rules.apiAccess.isSatisfiedBy(user)) {
features = features.add(FeatureSet.api())
}
return features
}
}Benefits demonstrated:
- Rules are testable in isolation
- New rules added without changing service
- Rules compose naturally
- Easy to understand eligibility criteria
---
Example 3: Data Import Pipeline
Demonstrates: Unit of Work, Repository, Domain Events
Problem Context
- Importing data from CSV requires atomic operations
- Import creates users, assigns to teams, triggers notifications
- Partial imports cause data inconsistency
Implementation
// ═══════════════════════════════════════════════════════════
// UNIT OF WORK
// ═══════════════════════════════════════════════════════════
class UnitOfWork {
constructor(connection) {
this.connection = connection
this.newEntities = []
this.dirtyEntities = []
this.deletedEntities = []
this.transaction = null
}
registerNew(entity) {
this.newEntities.push(entity)
}
registerDirty(entity) {
if (!this.dirtyEntities.includes(entity)) {
this.dirtyEntities.push(entity)
}
}
registerDeleted(entity) {
this.deletedEntities.push(entity)
}
async commit() {
this.transaction = await this.connection.beginTransaction()
try {
for (entity of this.newEntities) {
await this.insert(entity)
}
for (entity of this.dirtyEntities) {
await this.update(entity)
}
for (entity of this.deletedEntities) {
await this.delete(entity)
}
await this.transaction.commit()
this.clear()
} catch (error) {
await this.transaction.rollback()
throw error
}
}
clear() {
this.newEntities = []
this.dirtyEntities = []
this.deletedEntities = []
}
private async insert(entity) {
mapper = this.getMapper(entity)
await mapper.insert(entity, this.transaction)
}
// ... update, delete similar
}
// ═══════════════════════════════════════════════════════════
// IMPORT SERVICE
// ═══════════════════════════════════════════════════════════
class UserImportService {
constructor(
userRepo: UserRepository,
teamRepo: TeamRepository,
unitOfWorkFactory: () => UnitOfWork,
eventBus: EventBus
) {
this.userRepo = userRepo
this.teamRepo = teamRepo
this.createUnitOfWork = unitOfWorkFactory
this.events = eventBus
}
async importUsers(csvData: ParsedCSV) → ImportResult {
uow = this.createUnitOfWork()
importedUsers = []
errors = []
for (row of csvData.rows) {
try {
user = this.createUserFromRow(row)
uow.registerNew(user)
if (row.teamName) {
team = await this.teamRepo.findByName(row.teamName)
if (team) {
team.addMember(user)
uow.registerDirty(team)
}
}
importedUsers.push(user)
} catch (validationError) {
errors.push({ row: row.rowNumber, error: validationError })
}
}
if (errors.length > 0 && !csvData.options.allowPartial) {
// All or nothing
return ImportResult.failed(errors)
}
// Atomic commit
await uow.commit()
// Events published after successful commit
for (user of importedUsers) {
this.events.publish(new UserImported(user))
}
return ImportResult.success(importedUsers, errors)
}
}---
Testing Patterns
Testing with DI
// Unit test - all dependencies mocked
test("createOrder reserves inventory before payment") {
// Arrange
mockInventory = new MockInventoryService()
mockPayment = new MockPaymentGateway()
mockPayment.willReturn(PaymentResult.success())
service = new OrderService(
new InMemoryOrderRepository(),
mockInventory,
mockPayment,
new NullEventBus()
)
// Act
service.createOrder(customerId, items, paymentMethod)
// Assert
assert mockInventory.reserveCalledBefore(mockPayment.chargeCall)
}
// Integration test - real implementations
test("createOrder persists order with payment reference") {
// Real database, mock external payment
orderRepo = new PostgresOrderRepository(testDatabase)
mockPayment = new MockPaymentGateway()
mockPayment.willReturn(PaymentResult.success(paymentId))
service = new OrderService(
orderRepo,
new InventoryService(testDatabase),
mockPayment,
new InMemoryEventBus()
)
order = service.createOrder(customerId, items, paymentMethod)
persisted = orderRepo.findById(order.id)
assert persisted != null
assert persisted.paymentId == paymentId
}Testing Specifications
test("premium eligibility requires active subscription") {
spec = new HasActiveSubscription()
activeUser = User.create({ subscription: { status: "active" } })
inactiveUser = User.create({ subscription: { status: "cancelled" } })
noSubUser = User.create({ subscription: null })
assert spec.isSatisfiedBy(activeUser) == true
assert spec.isSatisfiedBy(inactiveUser) == false
assert spec.isSatisfiedBy(noSubUser) == false
}
test("composed specifications combine correctly") {
premiumEligible = and(
new HasActiveSubscription(),
new AccountAgeGreaterThan(30)
)
// Active subscription, account is 60 days old
eligible = User.create({
subscription: { status: "active" },
createdAt: daysAgo(60)
})
// Active subscription, account is 10 days old
tooNew = User.create({
subscription: { status: "active" },
createdAt: daysAgo(10)
})
assert premiumEligible.isSatisfiedBy(eligible) == true
assert premiumEligible.isSatisfiedBy(tooNew) == false
}Testing Circuit Breaker Behavior
test("circuit opens after failure threshold") {
failingOperation = () => { throw new Error("service down") }
circuit = new CircuitBreaker(failingOperation, {
failureThreshold: 3,
resetTimeout: 1000
})
// First 3 calls fail but attempt operation
for (i in 1..3) {
assertThrows(() => circuit.execute())
}
// 4th call fails immediately without attempting
assertThrows(CircuitOpenError, () => circuit.execute())
}Foundational Patterns
These patterns form the structural foundation for maintainable systems. Apply by default unless specific constraints prevent it.
Dependency Injection (DI)
What Problem It Solves
Classes that instantiate their own dependencies become:
- Untestable: Can't substitute test doubles
- Rigid: Changing implementation requires changing consumers
- Coupled: Component knows too much about its dependencies
The Pattern
Dependencies are passed to a component rather than created by it.
// BEFORE: Component creates dependency
class UserService {
notifier = new EmailNotifier() // Hardcoded
createUser(data) {
user = save(data)
this.notifier.send(user.email, "Welcome")
}
}
// AFTER: Dependency injected
class UserService {
constructor(notifier) {
this.notifier = notifier // Injected
}
createUser(data) {
user = save(data)
this.notifier.send(user.email, "Welcome")
}
}
// Usage
service = new UserService(new EmailNotifier()) // Production
testService = new UserService(new MockNotifier()) // TestingImplementation Approaches
Constructor Injection (Preferred) Dependencies passed via constructor. Makes dependencies explicit and immutable.
class OrderService {
constructor(repository, paymentGateway, notifier) {
this.repository = repository
this.paymentGateway = paymentGateway
this.notifier = notifier
}
}Setter Injection Dependencies set via methods. Allows optional dependencies but makes object state mutable.
class OrderService {
setRepository(repository) {
this.repository = repository
}
}Interface Injection Component declares interface for receiving dependencies. Less common.
Factory Functions Functional approach achieving same decoupling.
function createUserService(notifier) {
return {
createUser(data) {
user = save(data)
notifier.send(user.email, "Welcome")
return user
}
}
}DI Containers
Containers automate dependency wiring. Useful for large applications with many dependencies.
// Registration
container.register('Notifier', EmailNotifier)
container.register('UserRepository', PostgresUserRepository)
container.register('UserService', UserService, ['Notifier', 'UserRepository'])
// Resolution
service = container.resolve('UserService') // Wired automaticallyWhen to use containers:
- Many services with complex dependency graphs
- Multiple environments (dev/test/prod) with different implementations
- Need lifecycle management (singleton vs transient)
When NOT to use containers:
- Small applications (<10 services)
- Simple dependency graphs
- When explicit wiring provides clarity
When to Deviate from DI
Stable, stateless utilities Math functions, string utilities, pure functions with no state.
// OK to call directly - stable utility
hash = calculateSHA256(data)Framework-managed components When framework handles lifecycle (React components, HTTP handlers).
// Framework manages instantiation
function UserPage({ userId }) {
// OK: framework-injected props
}Value objects Immutable data carriers without behavior dependencies.
// OK: no dependencies
point = new Point(x, y)Testing with DI
DI enables test doubles:
// Unit test with mock
test("createUser sends notification") {
mockNotifier = new MockNotifier()
service = new UserService(mockNotifier)
service.createUser({ email: "test@example.com" })
assert mockNotifier.wasCalled()
assert mockNotifier.lastRecipient == "test@example.com"
}
// Integration test with real implementation
test("createUser stores in database") {
realRepo = new TestDatabaseRepository()
service = new UserService(new NullNotifier(), realRepo)
user = service.createUser({ email: "test@example.com" })
assert realRepo.findById(user.id) != null
}---
Service-Oriented Architecture (SOA)
What Problem It Solves
Without clear boundaries:
- Logic scatters: Same business rule in multiple places
- Changes ripple: Modifying one thing breaks unrelated things
- Ownership unclear: No one knows who maintains what
- Testing difficult: Can't test in isolation
The Pattern
Organize code into services with:
- Clear boundaries and responsibilities
- Well-defined interfaces
- Internal implementation hidden
- Explicit dependencies between services
// Service boundary
UserService
├── Interface (public contract)
│ ├── createUser(data) → User
│ ├── findById(id) → User | null
│ └── updateUser(id, data) → User
├── Implementation (hidden)
│ ├── validation logic
│ ├── persistence calls
│ └── event publishing
└── Dependencies (explicit)
├── UserRepository
├── Notifier
└── EventBusService Boundary Guidelines
By Business Capability Group by what the business does, not technical layers.
// GOOD: Business capabilities
UserService // User lifecycle
OrderService // Order processing
InventoryService // Stock management
// AVOID: Technical layers
DatabaseService // Too broad
ValidationService // Cross-cutting concernSingle Responsibility Each service owns one cohesive concept.
// GOOD: Focused responsibility
PaymentService {
processPayment(order, paymentMethod)
refund(paymentId, amount)
getPaymentStatus(paymentId)
}
// AVOID: Mixed responsibilities
PaymentAndShippingService {
processPayment(...)
calculateShipping(...) // Different concern
refund(...)
trackPackage(...) // Different concern
}Data Ownership Services own their data; others access via service interface.
// GOOD: UserService owns user data
OrderService {
createOrder(userId, items) {
user = this.userService.findById(userId) // Via service
// ...
}
}
// AVOID: Direct data access
OrderService {
createOrder(userId, items) {
user = this.database.query("SELECT * FROM users...") // Bypasses service
}
}Interface Design
Expose capabilities, not data structures
// GOOD: Capability-focused
interface InventoryService {
reserveStock(productId, quantity) → ReservationId
releaseReservation(reservationId)
checkAvailability(productId) → AvailabilityStatus
}
// AVOID: Data-focused
interface InventoryService {
getInventoryRecord(productId) → InventoryRecord // Exposes internal structure
updateInventoryRecord(record) // Allows arbitrary mutation
}Version interfaces when changing
// v1 still supported
interface UserService_v1 {
getUser(id) → { name, email }
}
// v2 adds fields
interface UserService_v2 {
getUser(id) → { name, email, preferences }
}Service Communication
Synchronous (request/response) For immediate, required responses.
// Order needs user data NOW
user = userService.findById(userId)
if (!user) throw new Error("User not found")Asynchronous (events) For notifications, eventual consistency.
// Order doesn't wait for notification
orderService.createOrder(data)
eventBus.publish(OrderCreated(order)) // Listeners handle asyncWhen to Merge Services
Chatty communication If services constantly call each other, they may be one service split wrong.
// Sign of wrong boundary
OrderService.createOrder() {
this.pricingService.calculatePrice()
this.pricingService.applyDiscounts()
this.pricingService.calculateTax()
this.pricingService.formatTotal()
}
// Consider: pricing is part of order creationShared data mutation If two services need to modify same data atomically, they may belong together.
Artificial separation If separation exists only for "clean architecture" but adds no value.
Testing Services
Unit tests: Mock dependencies, test service logic
test("createOrder validates user exists") {
mockUserService = new MockUserService()
mockUserService.willReturn(null) // User not found
orderService = new OrderService(mockUserService, ...)
assertThrows(() => orderService.createOrder(invalidUserId, items))
}Integration tests: Real dependencies, test service interactions
test("createOrder persists and publishes event") {
// Real implementations in test database
orderService = new OrderService(realUserService, realRepo, realEventBus)
order = orderService.createOrder(userId, items)
assert realRepo.findById(order.id) != null
assert realEventBus.lastEvent instanceof OrderCreated
}---
DI + SOA Together
The patterns complement each other:
- SOA defines service boundaries and interfaces
- DI manages dependencies between services
// SOA: Service boundaries
UserService ─────depends on────▶ UserRepository
│ │
│ ▼
└────depends on────▶ Notifier ◀─── DI: Injected at construction
// Composition root wires everything
function createApplication() {
repository = new PostgresUserRepository(dbConnection)
notifier = new EmailNotifier(smtpConfig)
userService = new UserService(repository, notifier)
return { userService }
}DI enables SOA testing Because services receive dependencies, you can test each service in isolation.
SOA guides DI boundaries Service interfaces define what gets injected where.
Situational Patterns
Apply these patterns when specific problems emerge. Don't apply preemptively.
Repository Pattern
What Problem It Solves
When services contain data access logic:
- Testing requires database: Can't unit test without real database
- Database changes ripple: Changing schema affects business logic
- Query duplication: Same queries written in multiple places
- Vendor lock-in: Database-specific code everywhere
The Pattern
Abstract data access behind a domain-focused interface.
// Interface speaks domain language
interface UserRepository {
findById(id) → User | null
findByEmail(email) → User | null
save(user) → User
delete(id) → void
findActiveUsers() → User[]
}
// Implementation hides database details
class PostgresUserRepository implements UserRepository {
constructor(connection) {
this.connection = connection
}
findById(id) {
row = this.connection.query("SELECT * FROM users WHERE id = $1", [id])
return row ? this.mapToUser(row) : null
}
findActiveUsers() {
rows = this.connection.query(
"SELECT * FROM users WHERE status = 'active' AND last_login > $1",
[thirtyDaysAgo]
)
return rows.map(this.mapToUser)
}
private mapToUser(row) {
return new User(row.id, row.email, row.name, ...)
}
}When to Apply
Apply when:
- Multiple services access same data
- Business logic mixes with SQL/queries
- Changing database would require touching business code
- Same queries appear in multiple places
Skip when:
- Simple CRUD with minimal logic
- Single service, simple data needs
- Framework provides adequate abstraction
Implementation Guidelines
Domain language in interface Methods named for business concepts, not database operations.
// GOOD: Domain language
interface OrderRepository {
findPendingOrders() → Order[]
findByCustomer(customerId) → Order[]
}
// AVOID: Database language
interface OrderRepository {
executeQuery(sql) → Row[]
findByColumn(column, value) → Row[]
}Return domain objects, not database rows
// GOOD: Returns domain entity
findById(id) → User
// AVOID: Returns raw data
findById(id) → { id: string, email: string, ... }Encapsulate complex queries
// Complex query logic hidden in repository
findEligibleForPromotion() {
// 20-line query with joins and conditions
// Business code just calls this method
}---
Domain Events
What Problem It Solves
When services call each other directly:
- Circular dependencies: Service A calls B calls C calls A
- Temporal coupling: All services must be available simultaneously
- Knowledge spread: Service A knows too much about B, C, D
- Difficult scaling: Adding new reactions requires modifying source
The Pattern
Services emit events; interested parties subscribe.
// BEFORE: Direct coupling
class UserService {
createUser(data) {
user = this.repository.save(data)
this.billingService.createAccount(user) // Knows about billing
this.notificationService.sendWelcome(user) // Knows about notifications
this.analyticsService.trackSignup(user) // Knows about analytics
return user
}
}
// AFTER: Event-driven
class UserService {
createUser(data) {
user = this.repository.save(data)
this.eventBus.publish(new UserCreated(user)) // Doesn't know who listens
return user
}
}
// Subscribers handle their own concerns
class BillingService {
onUserCreated(event) {
this.createAccount(event.user)
}
}
class NotificationService {
onUserCreated(event) {
this.sendWelcome(event.user)
}
}When to Apply
Apply when:
- Services call each other in cycles
- Adding new reactions requires modifying existing services
- Services need to react without blocking the source
- Multiple services need to know when something happens
Skip when:
- Immediate response required (use direct call)
- Only one consumer exists
- Debugging/tracing complexity outweighs benefits
- Simple linear flow without branching reactions
Event Design
Events are facts about the past
// GOOD: Past tense, fact
UserCreated { userId, timestamp }
OrderShipped { orderId, trackingNumber, timestamp }
// AVOID: Commands or requests
CreateUserAccount { ... }
PleaseShipOrder { ... }Include enough context
// GOOD: Self-contained
OrderCreated {
orderId
customerId
items: [{ productId, quantity, price }]
totalAmount
timestamp
}
// AVOID: Requires lookup
OrderCreated {
orderId // Consumer must call back to get details
}Events are immutable Once published, never modify. Version if schema changes.
Implementation Patterns
In-process event bus Simple, synchronous, same transaction.
class EventBus {
handlers = {}
subscribe(eventType, handler) {
this.handlers[eventType].push(handler)
}
publish(event) {
for (handler of this.handlers[event.type]) {
handler(event)
}
}
}Message queue (async) Durable, distributed, eventual consistency.
// Publisher
messageQueue.publish("user.created", event)
// Consumer (separate process)
messageQueue.subscribe("user.created", (event) => {
billingService.createAccount(event.user)
})---
Anti-Corruption Layer (ACL)
What Problem It Solves
When integrating external systems:
- API changes break your code: External update causes cascading failures
- Foreign concepts leak in: External terminology in your domain
- Testing difficulty: Can't test without external system
- Coupling to volatility: Your stability depends on their stability
The Pattern
Wrap external systems in your own interface that speaks your domain language.
// External API (you don't control)
StripeAPI {
createCustomer(email, source) → { customer_id, default_source }
chargeCustomer(customer_id, amount_cents, currency) → { charge_id, status }
}
// Your domain interface
interface PaymentProvider {
createCustomerAccount(customer: Customer) → AccountId
charge(accountId: AccountId, amount: Money) → ChargeResult
}
// ACL translates between them
class StripePaymentProvider implements PaymentProvider {
constructor(stripeApi) {
this.stripe = stripeApi
}
createCustomerAccount(customer) {
result = this.stripe.createCustomer(customer.email, customer.paymentSource)
return new AccountId(result.customer_id)
}
charge(accountId, amount) {
result = this.stripe.chargeCustomer(
accountId.value,
amount.toCents(),
amount.currency
)
return new ChargeResult(
result.charge_id,
this.mapStatus(result.status)
)
}
private mapStatus(stripeStatus) {
switch(stripeStatus) {
case "succeeded": return ChargeStatus.Completed
case "pending": return ChargeStatus.Processing
case "failed": return ChargeStatus.Failed
}
}
}When to Apply
Apply when:
- Integrating third-party APIs
- Wrapping legacy systems
- External system uses different terminology
- External system may be replaced
- External changes have caused production issues
Skip when:
- Internal service you control
- Simple, stable integration (logging, metrics)
- Translation overhead outweighs isolation benefit
Implementation Guidelines
Your interface, your language
// External uses "customer_id", you use "AccountId"
// External uses cents, you use Money value object
// External uses string status, you use enumHandle external failures
charge(accountId, amount) {
try {
result = this.stripe.chargeCustomer(...)
return success(this.mapResult(result))
} catch (StripeError e) {
return failure(this.mapError(e)) // Translate their errors too
}
}Version your interface, not theirs
// Your interface stays stable
interface PaymentProvider {
charge(accountId, amount) → ChargeResult
}
// ACL adapts to API changes internally
class StripePaymentProvider_v3 implements PaymentProvider {
// New Stripe API version, same interface
}---
Circuit Breaker
What Problem It Solves
When external services fail:
- Cascading failures: One slow service blocks everything
- Resource exhaustion: Threads/connections waiting on timeouts
- Poor user experience: Long waits before errors
- Recovery delay: Even after external recovers, backlog causes issues
The Pattern
Monitor failure rate; when threshold exceeded, fail fast without calling.
class CircuitBreaker {
state = CLOSED // CLOSED = normal, OPEN = failing fast, HALF_OPEN = testing
failureCount = 0
lastFailureTime = null
constructor(operation, options) {
this.operation = operation
this.failureThreshold = options.failureThreshold // e.g., 5
this.resetTimeout = options.resetTimeout // e.g., 30000ms
}
execute(...args) {
if (this.state == OPEN) {
if (timeSince(this.lastFailureTime) > this.resetTimeout) {
this.state = HALF_OPEN // Try one request
} else {
throw new CircuitOpenError() // Fail fast
}
}
try {
result = this.operation(...args)
this.onSuccess()
return result
} catch (error) {
this.onFailure()
throw error
}
}
onSuccess() {
this.failureCount = 0
this.state = CLOSED
}
onFailure() {
this.failureCount++
this.lastFailureTime = now()
if (this.failureCount >= this.failureThreshold) {
this.state = OPEN
}
}
}Usage
// Wrap external call
paymentCircuit = new CircuitBreaker(
(amount, customer) => stripeApi.charge(amount, customer),
{ failureThreshold: 5, resetTimeout: 30000 }
)
// Use wrapped operation
try {
result = paymentCircuit.execute(amount, customer)
} catch (CircuitOpenError) {
// Handle gracefully - maybe queue for retry, show message
return "Payment processing delayed, we'll charge you shortly"
}When to Apply
Apply when:
- Calling external services over network
- Downstream service has reliability issues
- Cascading failures have occurred
- System needs to degrade gracefully
Skip when:
- Local operations (no network)
- Failure is acceptable/expected (optional features)
- Already have retry/timeout at infrastructure level
Configuration Guidelines
Failure threshold Depends on normal error rate. If 1% errors normal, threshold of 5 in 100 requests.
Reset timeout Depends on typical recovery time. Start with 30 seconds, adjust based on observation.
Timeout per call Circuit breaker doesn't replace timeouts. Still timeout individual calls.
// Both timeout AND circuit breaker
paymentCircuit = new CircuitBreaker(
(amount, customer) => withTimeout(
stripeApi.charge(amount, customer),
5000 // 5 second timeout
),
{ failureThreshold: 5, resetTimeout: 30000 }
)---
Unit of Work
What Problem It Solves
When multiple repositories need atomic updates:
- Partial updates: First save succeeds, second fails, data inconsistent
- Transaction management: Business logic mixed with transaction code
- Scattered commits: Multiple commit points, hard to reason about
The Pattern
Coordinate multiple repository operations as single transaction.
class UnitOfWork {
private dirtyEntities = []
private newEntities = []
private deletedEntities = []
registerNew(entity) {
this.newEntities.push(entity)
}
registerDirty(entity) {
this.dirtyEntities.push(entity)
}
registerDeleted(entity) {
this.deletedEntities.push(entity)
}
commit() {
transaction = this.connection.beginTransaction()
try {
for (entity of this.newEntities) {
this.insert(entity)
}
for (entity of this.dirtyEntities) {
this.update(entity)
}
for (entity of this.deletedEntities) {
this.delete(entity)
}
transaction.commit()
} catch (error) {
transaction.rollback()
throw error
}
}
}Usage
function transferFunds(fromAccount, toAccount, amount, unitOfWork) {
fromAccount.debit(amount)
toAccount.credit(amount)
unitOfWork.registerDirty(fromAccount)
unitOfWork.registerDirty(toAccount)
unitOfWork.commit() // Both or neither
}When to Apply
Apply when:
- Multiple entities must update atomically
- Business operations span multiple repositories
- Need to defer persistence until operation complete
Skip when:
- Single entity operations
- Eventual consistency acceptable
- Framework provides transaction management
---
Specification Pattern
What Problem It Solves
When business rules become complex:
- Conditional spaghetti: Nested if statements for eligibility checks
- Duplication: Same rules in multiple places
- Untestable: Can't test rules in isolation
- Rigid: Adding new criteria requires modifying existing code
The Pattern
Encapsulate business rules as composable objects.
// Base specification
interface Specification<T> {
isSatisfiedBy(candidate: T) → boolean
}
// Concrete specifications
class HasActiveSubscription implements Specification<Customer> {
isSatisfiedBy(customer) {
return customer.subscription?.status == "active"
}
}
class AccountOlderThan implements Specification<Customer> {
constructor(days) {
this.days = days
}
isSatisfiedBy(customer) {
return daysSince(customer.createdAt) > this.days
}
}
// Composition
class AndSpecification implements Specification<T> {
constructor(left, right) {
this.left = left
this.right = right
}
isSatisfiedBy(candidate) {
return this.left.isSatisfiedBy(candidate)
&& this.right.isSatisfiedBy(candidate)
}
}
// Usage
eligibleForDiscount = and(
new HasActiveSubscription(),
new AccountOlderThan(30)
)
if (eligibleForDiscount.isSatisfiedBy(customer)) {
applyDiscount(order)
}When to Apply
Apply when:
- Complex eligibility or validation rules
- Rules reused in multiple contexts
- Rules need to be tested independently
- Rules change frequently
- Rules combine in different ways
Skip when:
- Simple boolean checks
- Rules never reused
- Overhead exceeds benefit