@schilling.mark.a/software-methodology 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/copilot-instructions.md +106 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/atdd-workflow/SKILL.md +117 -0
- package/atdd-workflow/references/green-phase.md +38 -0
- package/atdd-workflow/references/red-phase.md +62 -0
- package/atdd-workflow/references/refactor-phase.md +75 -0
- package/bdd-specification/SKILL.md +88 -0
- package/bdd-specification/references/example-mapping.md +105 -0
- package/bdd-specification/references/gherkin-patterns.md +214 -0
- package/cicd-pipeline/SKILL.md +64 -0
- package/cicd-pipeline/references/deployment-rollback.md +176 -0
- package/cicd-pipeline/references/environment-promotion.md +159 -0
- package/cicd-pipeline/references/pipeline-stages.md +198 -0
- package/clean-code/SKILL.md +77 -0
- package/clean-code/references/behavioral-patterns.md +329 -0
- package/clean-code/references/creational-patterns.md +197 -0
- package/clean-code/references/enterprise-patterns.md +334 -0
- package/clean-code/references/solid.md +230 -0
- package/clean-code/references/structural-patterns.md +238 -0
- package/continuous-improvement/SKILL.md +69 -0
- package/continuous-improvement/references/measurement.md +133 -0
- package/continuous-improvement/references/process-update.md +118 -0
- package/continuous-improvement/references/root-cause-analysis.md +144 -0
- package/dist/atdd-workflow.skill +0 -0
- package/dist/bdd-specification.skill +0 -0
- package/dist/cicd-pipeline.skill +0 -0
- package/dist/clean-code.skill +0 -0
- package/dist/continuous-improvement.skill +0 -0
- package/dist/green-implementation.skill +0 -0
- package/dist/product-strategy.skill +0 -0
- package/dist/story-mapping.skill +0 -0
- package/dist/ui-design-system.skill +0 -0
- package/dist/ui-design-workflow.skill +0 -0
- package/dist/ux-design.skill +0 -0
- package/dist/ux-research.skill +0 -0
- package/docs/INTEGRATION.md +229 -0
- package/docs/QUICKSTART.md +126 -0
- package/docs/SHARING.md +828 -0
- package/docs/SKILLS.md +296 -0
- package/green-implementation/SKILL.md +155 -0
- package/green-implementation/references/angular-patterns.md +239 -0
- package/green-implementation/references/common-rejections.md +180 -0
- package/green-implementation/references/playwright-patterns.md +321 -0
- package/green-implementation/references/rxjs-patterns.md +161 -0
- package/package.json +57 -0
- package/product-strategy/SKILL.md +71 -0
- package/product-strategy/references/business-model-canvas.md +199 -0
- package/product-strategy/references/canvas-alignment.md +108 -0
- package/product-strategy/references/value-proposition-canvas.md +159 -0
- package/project-templates/context.md.template +56 -0
- package/project-templates/test-strategy.md.template +87 -0
- package/story-mapping/SKILL.md +104 -0
- package/story-mapping/references/backbone.md +66 -0
- package/story-mapping/references/release-planning.md +92 -0
- package/story-mapping/references/task-template.md +78 -0
- package/story-mapping/references/walking-skeleton.md +63 -0
- package/ui-design-system/SKILL.md +48 -0
- package/ui-design-system/references/accessibility.md +134 -0
- package/ui-design-system/references/components.md +257 -0
- package/ui-design-system/references/design-tokens.md +209 -0
- package/ui-design-system/references/layout.md +136 -0
- package/ui-design-system/references/typography.md +114 -0
- package/ui-design-workflow/SKILL.md +90 -0
- package/ui-design-workflow/references/acceptance-targets.md +144 -0
- package/ui-design-workflow/references/component-selection.md +108 -0
- package/ui-design-workflow/references/scenario-to-ui.md +151 -0
- package/ui-design-workflow/references/screen-flows.md +116 -0
- package/ux-design/SKILL.md +75 -0
- package/ux-design/references/information-architecture.md +144 -0
- package/ux-design/references/interaction-patterns.md +141 -0
- package/ux-design/references/onboarding.md +159 -0
- package/ux-design/references/usability-evaluation.md +132 -0
- package/ux-research/SKILL.md +75 -0
- package/ux-research/references/journey-mapping.md +168 -0
- package/ux-research/references/mental-models.md +106 -0
- package/ux-research/references/personas.md +102 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# Enterprise Patterns
|
|
2
|
+
|
|
3
|
+
Patterns for structuring larger systems. These bridge the gap between domain logic and infrastructure concerns.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Repository
|
|
8
|
+
|
|
9
|
+
**When to use:**
|
|
10
|
+
- You want to decouple domain logic from data access
|
|
11
|
+
- You want your business logic to be testable without a real database
|
|
12
|
+
- You want a consistent interface for querying and persisting domain objects
|
|
13
|
+
|
|
14
|
+
**Smell that triggers it:**
|
|
15
|
+
- SQL queries or ORM calls directly inside business logic classes
|
|
16
|
+
- Changing the database requires changes in multiple business classes
|
|
17
|
+
- Unit tests require a running database
|
|
18
|
+
|
|
19
|
+
**Solution:**
|
|
20
|
+
```
|
|
21
|
+
// Interface defined by the DOMAIN — not by the database
|
|
22
|
+
interface InvoiceRepository:
|
|
23
|
+
findById(id: string): Invoice | null
|
|
24
|
+
findByCustomer(customerId: string): Invoice[]
|
|
25
|
+
findUnpaid(): Invoice[]
|
|
26
|
+
save(invoice: Invoice): void
|
|
27
|
+
delete(id: string): void
|
|
28
|
+
|
|
29
|
+
// Implementation lives in infrastructure layer
|
|
30
|
+
class PostgresInvoiceRepository implements InvoiceRepository:
|
|
31
|
+
constructor(db: DatabaseConnection)
|
|
32
|
+
|
|
33
|
+
findById(id):
|
|
34
|
+
row = this.db.query("SELECT * FROM invoices WHERE id = $1", [id])
|
|
35
|
+
return this.mapToInvoice(row)
|
|
36
|
+
|
|
37
|
+
save(invoice):
|
|
38
|
+
if invoice.isNew():
|
|
39
|
+
this.db.query("INSERT INTO invoices ...", [...])
|
|
40
|
+
else:
|
|
41
|
+
this.db.query("UPDATE invoices SET ... WHERE id = $1", [...])
|
|
42
|
+
|
|
43
|
+
// Business logic knows only the interface
|
|
44
|
+
class InvoiceService:
|
|
45
|
+
constructor(repo: InvoiceRepository)
|
|
46
|
+
|
|
47
|
+
markOverdue():
|
|
48
|
+
unpaid = this.repo.findUnpaid()
|
|
49
|
+
unpaid.forEach(inv => inv.markOverdue())
|
|
50
|
+
unpaid.forEach(inv => this.repo.save(inv))
|
|
51
|
+
|
|
52
|
+
// Test uses an in-memory implementation
|
|
53
|
+
class InMemoryInvoiceRepository implements InvoiceRepository:
|
|
54
|
+
private store: Map<string, Invoice> = new Map()
|
|
55
|
+
findById(id): return this.store.get(id)
|
|
56
|
+
save(invoice): this.store.set(invoice.id, invoice)
|
|
57
|
+
// ... rest of interface
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Key insight:** The interface is owned by the domain layer. The implementation is owned by the infrastructure layer. Business logic never knows which database is behind the repository.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Service Layer
|
|
65
|
+
|
|
66
|
+
**When to use:**
|
|
67
|
+
- You need a clear boundary between the application's business logic and its callers (UI, API, CLI)
|
|
68
|
+
- Multiple entry points (REST API, GraphQL, CLI) need to perform the same business operations
|
|
69
|
+
- You want to define the set of operations an application supports in one place
|
|
70
|
+
|
|
71
|
+
**Smell that triggers it:**
|
|
72
|
+
- Business logic living directly in controllers or route handlers
|
|
73
|
+
- The same business operation duplicated across multiple entry points
|
|
74
|
+
- No clear boundary between "what the application does" and "how it's invoked"
|
|
75
|
+
|
|
76
|
+
**Solution:**
|
|
77
|
+
```
|
|
78
|
+
// Service layer defines application operations
|
|
79
|
+
class InvoiceService:
|
|
80
|
+
constructor(repo: InvoiceRepository, taxEngine: TaxEngine, notifier: Notifier)
|
|
81
|
+
|
|
82
|
+
createInvoice(data: CreateInvoiceDTO): Invoice
|
|
83
|
+
customer = this.repo.findCustomer(data.customerId)
|
|
84
|
+
invoice = new Invoice(customer, data.lineItems)
|
|
85
|
+
invoice.applyTax(this.taxEngine.calculate(invoice))
|
|
86
|
+
this.repo.save(invoice)
|
|
87
|
+
this.notifier.send(invoice)
|
|
88
|
+
return invoice
|
|
89
|
+
|
|
90
|
+
payInvoice(invoiceId: string, paymentData: PaymentDTO): PaymentResult
|
|
91
|
+
invoice = this.repo.findById(invoiceId)
|
|
92
|
+
invoice.pay(paymentData)
|
|
93
|
+
this.repo.save(invoice)
|
|
94
|
+
return new PaymentResult(invoice)
|
|
95
|
+
|
|
96
|
+
// REST controller — thin, delegates everything
|
|
97
|
+
class InvoiceController:
|
|
98
|
+
constructor(service: InvoiceService)
|
|
99
|
+
|
|
100
|
+
POST /invoices:
|
|
101
|
+
return this.service.createInvoice(requestBody)
|
|
102
|
+
|
|
103
|
+
POST /invoices/:id/pay:
|
|
104
|
+
return this.service.payInvoice(params.id, requestBody)
|
|
105
|
+
|
|
106
|
+
// CLI — same service, different entry point
|
|
107
|
+
class InvoiceCLI:
|
|
108
|
+
constructor(service: InvoiceService)
|
|
109
|
+
|
|
110
|
+
handleCreateCommand(args):
|
|
111
|
+
invoice = this.service.createInvoice(parseArgs(args))
|
|
112
|
+
print(invoice.toString())
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Key insight:** The service layer is the single source of truth for what the application can do. Controllers and CLI handlers are thin wrappers that handle serialization and invocation — nothing else.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Unit of Work
|
|
120
|
+
|
|
121
|
+
**When to use:**
|
|
122
|
+
- Multiple domain objects change as part of a single business operation
|
|
123
|
+
- All changes must succeed or all must fail (transactional consistency)
|
|
124
|
+
- You want to batch database writes rather than writing after every single change
|
|
125
|
+
|
|
126
|
+
**Smell that triggers it:**
|
|
127
|
+
- Multiple `repository.save()` calls in sequence — if the second fails, the first is already committed
|
|
128
|
+
- Business logic that manually manages transactions
|
|
129
|
+
|
|
130
|
+
**Solution:**
|
|
131
|
+
```
|
|
132
|
+
class UnitOfWork:
|
|
133
|
+
private dirty: DomainObject[] = []
|
|
134
|
+
private newObjects: DomainObject[] = []
|
|
135
|
+
private removedObjects: DomainObject[] = []
|
|
136
|
+
|
|
137
|
+
register(obj: DomainObject):
|
|
138
|
+
this.dirty.push(obj)
|
|
139
|
+
|
|
140
|
+
registerNew(obj: DomainObject):
|
|
141
|
+
this.newObjects.push(obj)
|
|
142
|
+
|
|
143
|
+
registerRemoved(obj: DomainObject):
|
|
144
|
+
this.removedObjects.push(obj)
|
|
145
|
+
|
|
146
|
+
commit(): void
|
|
147
|
+
transaction = db.beginTransaction()
|
|
148
|
+
try:
|
|
149
|
+
this.newObjects.forEach(obj => db.insert(obj))
|
|
150
|
+
this.dirty.forEach(obj => db.update(obj))
|
|
151
|
+
this.removedObjects.forEach(obj => db.delete(obj))
|
|
152
|
+
transaction.commit()
|
|
153
|
+
catch:
|
|
154
|
+
transaction.rollback()
|
|
155
|
+
throw
|
|
156
|
+
|
|
157
|
+
// Service uses UoW for transactional consistency
|
|
158
|
+
class InvoiceService:
|
|
159
|
+
constructor(uow: UnitOfWork, repo: InvoiceRepository)
|
|
160
|
+
|
|
161
|
+
processPayment(invoiceId, paymentData):
|
|
162
|
+
invoice = this.repo.findById(invoiceId)
|
|
163
|
+
payment = new Payment(paymentData)
|
|
164
|
+
invoice.pay(payment)
|
|
165
|
+
|
|
166
|
+
this.uow.register(invoice) ← mark as changed
|
|
167
|
+
this.uow.registerNew(payment) ← mark as new
|
|
168
|
+
this.uow.commit() ← single transaction: both or neither
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Key insight:** Business logic describes *what* changed. Unit of Work handles *when* and *how* it's persisted. If anything fails, nothing is committed.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Specification
|
|
176
|
+
|
|
177
|
+
**When to use:**
|
|
178
|
+
- You have complex business rules that need to be combined, reused, or queried against
|
|
179
|
+
- You need to express "does this object match these criteria?" as a composable object
|
|
180
|
+
- The same selection logic is used in both validation and querying
|
|
181
|
+
|
|
182
|
+
**Smell that triggers it:**
|
|
183
|
+
- Long boolean expressions for filtering or validation
|
|
184
|
+
- The same filtering logic duplicated in business rules and database queries
|
|
185
|
+
- Complex conditions that are hard to name or reuse
|
|
186
|
+
|
|
187
|
+
**Solution:**
|
|
188
|
+
```
|
|
189
|
+
interface Specification:
|
|
190
|
+
isSatisfiedBy(candidate): boolean
|
|
191
|
+
and(other: Specification): Specification
|
|
192
|
+
or(other: Specification): Specification
|
|
193
|
+
not(): Specification
|
|
194
|
+
|
|
195
|
+
class UnpaidInvoiceSpec implements Specification:
|
|
196
|
+
isSatisfiedBy(invoice): return invoice.status == "unpaid"
|
|
197
|
+
|
|
198
|
+
class OverdueDateSpec implements Specification:
|
|
199
|
+
constructor(referenceDate: Date)
|
|
200
|
+
isSatisfiedBy(invoice): return invoice.dueDate < this.referenceDate
|
|
201
|
+
|
|
202
|
+
class MinimumAmountSpec implements Specification:
|
|
203
|
+
constructor(minAmount: Money)
|
|
204
|
+
isSatisfiedBy(invoice): return invoice.total() >= this.minAmount
|
|
205
|
+
|
|
206
|
+
// Compose specifications
|
|
207
|
+
overdueAndUnpaid = new UnpaidInvoiceSpec().and(new OverdueDateSpec(today))
|
|
208
|
+
highValueOverdue = overdueAndUnpaid.and(new MinimumAmountSpec(Money(10000)))
|
|
209
|
+
|
|
210
|
+
// Use in business logic
|
|
211
|
+
invoices.filter(inv => highValueOverdue.isSatisfiedBy(inv))
|
|
212
|
+
|
|
213
|
+
// Use in validation
|
|
214
|
+
if not unpaidSpec.isSatisfiedBy(invoice):
|
|
215
|
+
throw Error("Invoice is not in unpaid state")
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**Key insight:** Business rules become named, composable objects. "High-value overdue unpaid invoices" is expressed as composed specifications, not a deeply nested boolean expression.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Value Object
|
|
223
|
+
|
|
224
|
+
**When to use:**
|
|
225
|
+
- An object represents a concept defined entirely by its attributes, not by identity
|
|
226
|
+
- Two instances with the same attributes are considered equal
|
|
227
|
+
- The object should be immutable once created
|
|
228
|
+
|
|
229
|
+
**Smell that triggers it:**
|
|
230
|
+
- Raw primitives (numbers, strings) representing domain concepts like money, email, coordinates
|
|
231
|
+
- Repeated validation logic for the same concept scattered across the codebase
|
|
232
|
+
- Equality checks on raw values that should carry meaning
|
|
233
|
+
|
|
234
|
+
**Solution:**
|
|
235
|
+
```
|
|
236
|
+
class Money:
|
|
237
|
+
constructor(amount: number, currency: string)
|
|
238
|
+
if amount < 0: throw Error("Amount cannot be negative")
|
|
239
|
+
if not validCurrency(currency): throw Error("Invalid currency")
|
|
240
|
+
this.amount = amount
|
|
241
|
+
this.currency = currency
|
|
242
|
+
freeze(this) ← immutable
|
|
243
|
+
|
|
244
|
+
add(other: Money): Money
|
|
245
|
+
if this.currency != other.currency:
|
|
246
|
+
throw Error("Cannot add different currencies")
|
|
247
|
+
return new Money(this.amount + other.amount, this.currency)
|
|
248
|
+
|
|
249
|
+
multiply(factor: number): Money
|
|
250
|
+
return new Money(this.amount * factor, this.currency)
|
|
251
|
+
|
|
252
|
+
equals(other: Money): boolean
|
|
253
|
+
return this.amount == other.amount && this.currency == other.currency
|
|
254
|
+
|
|
255
|
+
toString(): string
|
|
256
|
+
return formatCurrency(this.amount, this.currency) // "$1,000.00"
|
|
257
|
+
|
|
258
|
+
class Email:
|
|
259
|
+
constructor(address: string)
|
|
260
|
+
if not isValidEmail(address): throw Error("Invalid email: " + address)
|
|
261
|
+
this.address = address.toLowerCase()
|
|
262
|
+
freeze(this)
|
|
263
|
+
|
|
264
|
+
equals(other: Email): boolean
|
|
265
|
+
return this.address == other.address
|
|
266
|
+
|
|
267
|
+
// Usage — no raw primitives in domain logic
|
|
268
|
+
class Invoice:
|
|
269
|
+
constructor(customer: Customer, total: Money)
|
|
270
|
+
this.customer = customer
|
|
271
|
+
this.total = total // Money, not number
|
|
272
|
+
|
|
273
|
+
addLineItem(item: LineItem):
|
|
274
|
+
this.total = this.total.add(item.amount) // Money.add, not +
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Key insight:** Value objects move validation to the point of creation. A `Money` object can never hold an invalid amount. A raw number can. Every domain concept that has validation rules and natural equality should be a value object.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Domain Events
|
|
282
|
+
|
|
283
|
+
**When to use:**
|
|
284
|
+
- Something happened in the domain that other parts of the system should know about
|
|
285
|
+
- You want to decouple the thing that happened from the reactions to it
|
|
286
|
+
- You want an audit trail of what occurred in the domain
|
|
287
|
+
|
|
288
|
+
**Smell that triggers it:**
|
|
289
|
+
- A method that directly calls multiple other systems after completing its work
|
|
290
|
+
- Business logic that knows about email, logging, or external APIs
|
|
291
|
+
- No record of what happened in the domain over time
|
|
292
|
+
|
|
293
|
+
**Solution:**
|
|
294
|
+
```
|
|
295
|
+
// Events are simple data objects — immutable records of what happened
|
|
296
|
+
class InvoiceCreatedEvent:
|
|
297
|
+
constructor(invoiceId, customerId, amount, createdAt)
|
|
298
|
+
// No behavior — just data
|
|
299
|
+
|
|
300
|
+
class InvoicePaidEvent:
|
|
301
|
+
constructor(invoiceId, transactionId, paidAt)
|
|
302
|
+
|
|
303
|
+
// Domain object collects events, doesn't publish them
|
|
304
|
+
class Invoice:
|
|
305
|
+
private events: DomainEvent[] = []
|
|
306
|
+
|
|
307
|
+
markPaid(transactionId):
|
|
308
|
+
this.status = "paid"
|
|
309
|
+
this.events.push(new InvoicePaidEvent(this.id, transactionId, now()))
|
|
310
|
+
|
|
311
|
+
getEvents(): DomainEvent[]
|
|
312
|
+
return this.events
|
|
313
|
+
|
|
314
|
+
// Service publishes events AFTER the transaction commits
|
|
315
|
+
class InvoiceService:
|
|
316
|
+
constructor(repo: InvoiceRepository, eventBus: EventBus)
|
|
317
|
+
|
|
318
|
+
payInvoice(invoiceId, paymentData):
|
|
319
|
+
invoice = this.repo.findById(invoiceId)
|
|
320
|
+
invoice.markPaid(paymentData.transactionId)
|
|
321
|
+
this.repo.save(invoice)
|
|
322
|
+
invoice.getEvents().forEach(e => this.eventBus.publish(e))
|
|
323
|
+
|
|
324
|
+
// Handlers react to events — completely decoupled
|
|
325
|
+
class EmailOnPaymentHandler:
|
|
326
|
+
handle(event: InvoicePaidEvent):
|
|
327
|
+
sendPaymentConfirmation(event.invoiceId)
|
|
328
|
+
|
|
329
|
+
class AuditOnPaymentHandler:
|
|
330
|
+
handle(event: InvoicePaidEvent):
|
|
331
|
+
auditLog.record(event)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**Key insight:** The domain object doesn't know who's listening. It just records what happened. The event bus delivers events to handlers after the fact. New reaction = new handler, zero changes to the domain.
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# SOLID Principles
|
|
2
|
+
|
|
3
|
+
## Single Responsibility Principle (SRP)
|
|
4
|
+
|
|
5
|
+
**Rule:** A class has exactly one reason to change.
|
|
6
|
+
|
|
7
|
+
**Smell that triggers it:**
|
|
8
|
+
- Class has methods that serve different concerns
|
|
9
|
+
- Changing one feature requires editing this class even though the feature has nothing to do with the class's core purpose
|
|
10
|
+
- Class name is vague or uses "and" (e.g., `InvoiceManager`)
|
|
11
|
+
|
|
12
|
+
**Before:**
|
|
13
|
+
```
|
|
14
|
+
class Invoice:
|
|
15
|
+
calculateTotal() ← business logic
|
|
16
|
+
formatAsPDF() ← presentation
|
|
17
|
+
sendByEmail() ← communication
|
|
18
|
+
saveToDatabase() ← persistence
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**After:**
|
|
22
|
+
```
|
|
23
|
+
class Invoice: ← owns business logic only
|
|
24
|
+
calculateTotal()
|
|
25
|
+
|
|
26
|
+
class InvoicePDFRenderer: ← owns presentation
|
|
27
|
+
render(invoice)
|
|
28
|
+
|
|
29
|
+
class InvoiceNotifier: ← owns communication
|
|
30
|
+
send(invoice)
|
|
31
|
+
|
|
32
|
+
class InvoiceRepository: ← owns persistence
|
|
33
|
+
save(invoice)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Key insight:** "Reason to change" means a business requirement change. If marketing changes the PDF layout, only `InvoicePDFRenderer` changes. If tax rules change, only `Invoice` changes.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Open/Closed Principle (OCP)
|
|
41
|
+
|
|
42
|
+
**Rule:** Open for extension, closed for modification.
|
|
43
|
+
|
|
44
|
+
**Smell that triggers it:**
|
|
45
|
+
- Adding a new variant requires editing existing code
|
|
46
|
+
- if/else or switch statements that grow with each new requirement
|
|
47
|
+
- A method that checks "what type of thing am I dealing with?"
|
|
48
|
+
|
|
49
|
+
**Before:**
|
|
50
|
+
```
|
|
51
|
+
class TaxCalculator:
|
|
52
|
+
calculate(invoice):
|
|
53
|
+
if invoice.region == "US":
|
|
54
|
+
return invoice.total * 0.08
|
|
55
|
+
if invoice.region == "UK":
|
|
56
|
+
return invoice.total * 0.20
|
|
57
|
+
if invoice.region == "CA": ← every new region edits this class
|
|
58
|
+
return invoice.total * 0.05
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**After:**
|
|
62
|
+
```
|
|
63
|
+
interface TaxRule:
|
|
64
|
+
calculate(invoice): Money
|
|
65
|
+
|
|
66
|
+
class UStaxRule implements TaxRule:
|
|
67
|
+
calculate(invoice): return invoice.total * 0.08
|
|
68
|
+
|
|
69
|
+
class UKTaxRule implements TaxRule:
|
|
70
|
+
calculate(invoice): return invoice.total * 0.20
|
|
71
|
+
|
|
72
|
+
class TaxCalculator:
|
|
73
|
+
constructor(rule: TaxRule) ← new region = new class, no edits
|
|
74
|
+
calculate(invoice): return this.rule.calculate(invoice)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Key insight:** New behavior is added by writing new code (new class), not by editing existing code. The `TaxCalculator` never changes again.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Liskov Substitution Principle (LSP)
|
|
82
|
+
|
|
83
|
+
**Rule:** A subtype must be usable anywhere its base type is expected, without changing behavior.
|
|
84
|
+
|
|
85
|
+
**Smell that triggers it:**
|
|
86
|
+
- A subclass overrides a method and throws an exception or returns nothing
|
|
87
|
+
- Code that checks `if (thing instanceof SpecificSubclass)` before acting
|
|
88
|
+
- A subclass that weakens a precondition or strengthens a postcondition
|
|
89
|
+
|
|
90
|
+
**Before:**
|
|
91
|
+
```
|
|
92
|
+
class Shape:
|
|
93
|
+
area(): number
|
|
94
|
+
|
|
95
|
+
class Circle extends Shape:
|
|
96
|
+
area(): return π * r²
|
|
97
|
+
|
|
98
|
+
class Square extends Shape:
|
|
99
|
+
area(): return side²
|
|
100
|
+
|
|
101
|
+
class ReadOnlySquare extends Square:
|
|
102
|
+
setSize(): throw Error("Cannot modify") ← violates LSP
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**After:**
|
|
106
|
+
```
|
|
107
|
+
interface Shape:
|
|
108
|
+
area(): number ← read-only contract
|
|
109
|
+
|
|
110
|
+
class Circle implements Shape:
|
|
111
|
+
area(): return π * r²
|
|
112
|
+
|
|
113
|
+
class Square implements Shape:
|
|
114
|
+
area(): return side²
|
|
115
|
+
|
|
116
|
+
class MutableSquare extends Square:
|
|
117
|
+
setSize(s): this.side = s ← mutation is opt-in, not broken inheritance
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Key insight:** If a subclass has to break the parent's contract to work correctly, the hierarchy is wrong. Flatten it or use composition.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Interface Segregation Principle (ISP)
|
|
125
|
+
|
|
126
|
+
**Rule:** Clients should not be forced to depend on methods they do not use.
|
|
127
|
+
|
|
128
|
+
**Smell that triggers it:**
|
|
129
|
+
- A class implements an interface but leaves methods empty or throwing
|
|
130
|
+
- An interface with many methods, most of which any given consumer ignores
|
|
131
|
+
- "Fat interface" that accumulates methods over time
|
|
132
|
+
|
|
133
|
+
**Before:**
|
|
134
|
+
```
|
|
135
|
+
interface Repository:
|
|
136
|
+
findById(id)
|
|
137
|
+
findAll()
|
|
138
|
+
save(entity)
|
|
139
|
+
delete(entity)
|
|
140
|
+
bulkImport(entities) ← only used by one import service
|
|
141
|
+
generateReport() ← only used by reporting
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**After:**
|
|
145
|
+
```
|
|
146
|
+
interface Reader:
|
|
147
|
+
findById(id)
|
|
148
|
+
findAll()
|
|
149
|
+
|
|
150
|
+
interface Writer:
|
|
151
|
+
save(entity)
|
|
152
|
+
delete(entity)
|
|
153
|
+
|
|
154
|
+
interface BulkImporter:
|
|
155
|
+
bulkImport(entities)
|
|
156
|
+
|
|
157
|
+
class InvoiceRepository implements Reader, Writer, BulkImporter:
|
|
158
|
+
// ... implements all
|
|
159
|
+
|
|
160
|
+
class InvoiceService:
|
|
161
|
+
constructor(reader: Reader, writer: Writer) ← only sees what it needs
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Key insight:** Each consumer declares the interface it needs. The concrete class implements all of them. Consumers are decoupled from each other.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Dependency Inversion Principle (DIP)
|
|
169
|
+
|
|
170
|
+
**Rule:** High-level modules depend on abstractions. Low-level modules also depend on abstractions. Abstractions do not depend on details.
|
|
171
|
+
|
|
172
|
+
**Smell that triggers it:**
|
|
173
|
+
- A business logic class directly instantiates or imports infrastructure (database, HTTP client, file system)
|
|
174
|
+
- Changing a data store requires editing business logic
|
|
175
|
+
- Unit tests require a real database or network call
|
|
176
|
+
|
|
177
|
+
**Before:**
|
|
178
|
+
```
|
|
179
|
+
class InvoiceService:
|
|
180
|
+
constructor():
|
|
181
|
+
this.db = new PostgresDatabase() ← concrete dependency
|
|
182
|
+
this.emailer = new SMTPEmailer() ← concrete dependency
|
|
183
|
+
|
|
184
|
+
createInvoice(data):
|
|
185
|
+
invoice = new Invoice(data)
|
|
186
|
+
this.db.save(invoice) ← can't test without Postgres
|
|
187
|
+
this.emailer.send(invoice) ← can't test without SMTP
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**After:**
|
|
191
|
+
```
|
|
192
|
+
interface InvoiceRepository: ← abstraction
|
|
193
|
+
save(invoice)
|
|
194
|
+
|
|
195
|
+
interface Notifier: ← abstraction
|
|
196
|
+
send(invoice)
|
|
197
|
+
|
|
198
|
+
class InvoiceService:
|
|
199
|
+
constructor(repo: InvoiceRepository, notifier: Notifier) ← depends on abstractions
|
|
200
|
+
this.repo = repo
|
|
201
|
+
this.notifier = notifier
|
|
202
|
+
|
|
203
|
+
createInvoice(data):
|
|
204
|
+
invoice = new Invoice(data)
|
|
205
|
+
this.repo.save(invoice)
|
|
206
|
+
this.notifier.send(invoice)
|
|
207
|
+
|
|
208
|
+
// Production wiring:
|
|
209
|
+
service = new InvoiceService(new PostgresInvoiceRepository(), new SMTPNotifier())
|
|
210
|
+
|
|
211
|
+
// Test wiring:
|
|
212
|
+
service = new InvoiceService(mockRepository, mockNotifier) ← no real infra needed
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Key insight:** The business logic (`InvoiceService`) never knows about Postgres or SMTP. It knows only the contracts it needs. This is the foundation that makes unit testing fast and isolated.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Applying SOLID in Practice
|
|
220
|
+
|
|
221
|
+
**Priority order when multiple principles apply:**
|
|
222
|
+
1. DIP first — if you can't inject dependencies, nothing else matters for testability
|
|
223
|
+
2. SRP — identify the single responsibility before extracting
|
|
224
|
+
3. OCP — once responsibilities are clear, make extension points
|
|
225
|
+
4. ISP — split interfaces when consumers only need subsets
|
|
226
|
+
5. LSP — verify inheritance hierarchies don't break contracts
|
|
227
|
+
|
|
228
|
+
**During GREEN:** Only apply DIP if the current test literally cannot pass without it (e.g., test needs to inject a mock). Everything else waits for REFACTOR.
|
|
229
|
+
|
|
230
|
+
**During REFACTOR:** Walk through the list above. Apply only what the current code requires. Rule of Three applies — don't extract until the second occurrence.
|