Software craft · Cliff notes
SOLID Principles
Five ways to make responsibilities, contracts, and dependencies easier to change.
- SOLID
- Responsibilities
- Contracts
- Dependencies
SOLID names five object-oriented design principles. They help answer where a change belongs, what a caller may assume, and which details a policy should know about. They are design prompts, not a required number of classes or protocols.
Start with the one-line rules, then open a point to compare a better pattern with a costly one. Choose Swift, Python, JavaScript, TypeScript, C#, Kotlin, Java, or C++ in any point to update every example on the page. Each example illustrates a review decision rather than a complete application.
The five principles at a glance
Open a point for paired examples. Choose a language in any point to update every example. Hold Shift to keep other examples open.
1 / S
Single responsibility
Give a unit one coherent reason to change. A responsibility can include several related steps. Split a type when unrelated policies, actors, or change requests repeatedly pull it in different directions.
Watch for: One type changes for pricing rules, storage schema, and presentation copy.
1.1 Separate independent reasons to change. Keep pricing, persistence, and presentation policies in their own homes.
InvoiceService puts tax rules, SQL storage, and PDF layout in one type. A tax change, schema migration, or layout request would all require editing that same service.
Better example
struct InvoicePricing { func total(for invoice: Invoice) -> Decimal { /* tax rules */ } } struct InvoiceStore { func save(_ invoice: Invoice) throws { /* SQL schema */ } } struct InvoiceRenderer { func render(_ invoice: Invoice) -> Data { /* PDF layout */ } }Better — InvoicePricing, InvoiceStore, and InvoiceRenderer each own one of those changes, so editing the tax calculation does not reopen the SQL or PDF code.
Costly example
final class InvoiceService { func total(_ invoice: Invoice) -> Decimal { /* tax rules */ } func save(_ invoice: Invoice) throws { /* SQL schema */ } func render(_ invoice: Invoice) -> Data { /* PDF layout */ } }Costly — InvoiceService combines all three operations, so an unrelated storage or PDF edit can affect the type used for pricing.
Ask who requests each change, not how many methods a type has.
1.2 Keep one concept cohesive. Related behavior may belong together even when it uses several methods.
A cart total must apply the discount to the subtotal in the right order. Separate CartSubtotal, CartDiscount, and CartTotal types leave that sequence to every caller.
Better example
struct CartPricing { func total(for lines: [Line]) -> Decimal { let subtotal = subtotal(for: lines) return subtotal - discount(for: lines) } private func subtotal(for lines: [Line]) -> Decimal { /* … */ } private func discount(for lines: [Line]) -> Decimal { /* … */ } }Better — CartPricing.total computes the subtotal and subtracts the discount itself, keeping the calculation order in one place.
Costly example
struct CartSubtotal { func calculate(_ lines: [Line]) -> Decimal } struct CartDiscount { func calculate(_ lines: [Line]) -> Decimal } struct CartTotal { func calculate(_ lines: [Line]) -> Decimal } // Each caller must combine these in the right order.Costly — Callers of the three cart types must assemble the steps and can omit a discount or pass the wrong amount to the total.
A responsibility can include several steps that serve one policy.
1.3 Move presentation out of domain rules. Let domain decisions stay independent of screen or document layout.
Subscription.renewalMessage embeds renewal wording in the domain type. Changing that text would require a domain edit even when expiration rules stay the same.
Better example
struct Subscription { var needsRenewal: Bool { isExpired } } struct RenewalMessage { func text(for subscription: Subscription) -> String { subscription.needsRenewal ? "Renew now in Settings" : "Active" } }Better — Subscription exposes the renewal decision, while a separate presentation function or type chooses the text, so a copy change stays out of domain code.
Costly example
struct Subscription { func renewalMessage() -> String { isExpired ? "Renew now in Settings" : "Active" } }Costly — Subscription.renewalMessage ties the domain model to one message and forces other screens to reuse or work around that wording.
Separate responsibilities when different policies drive their changes.
2 / O
Open/closed
Extend an intended variation at a stable boundary. Identify the kind of change that recurs. A narrow extension point can protect tested policy, while a fixed set of alternatives may be clearer as an exhaustive enum.
Watch for: Each new variant requires edits to the same branching logic in several callers.
2.1 Extend a real variation point. Put recurring variants behind a stable, narrow policy role.
The discount function checks a kind for student and member promotions. Each new promotion adds another branch to the same function.
Better example
protocol DiscountPolicy { func discount(for order: Order) -> Decimal } struct StudentDiscount: DiscountPolicy { func discount(for order: Order) -> Decimal { order.total * 0.1 } } func total(_ order: Order, using policy: DiscountPolicy) -> Decimal { order.total - policy.discount(for: order) }Better — The total calculation accepts a discount policy, so a new promotion can supply its rule without changing that calculation.
Costly example
func discount(for order: Order, kind: String) -> Decimal { if kind == "student" { return order.total * 0.1 } if kind == "member" { return order.total * 0.2 } return 0 // Edit here for every new promotion. }Costly — Adding a promotion means editing the branching discount function and retesting its student and member cases.
Introduce an extension point when the variation actually recurs.
2.2 Use enums for a closed set. A fixed set can be clearer than extensibility you do not need.
Idle, Busy, and other loading states are a fixed set for this screen. One type per state hides the full set and makes its labels harder to review together.
Better example
enum LoadingState { case idle, busy, failed var label: String { switch self { case .idle: "Ready" case .busy: "Loading" case .failed: "Try again" } } }Better — LoadingState keeps idle, busy, and failed with their labels, so the fixed set can be reviewed in one place.
Costly example
protocol LoadingState { func label() -> String } struct Idle: LoadingState { func label() -> String { "Ready" } } struct Busy: LoadingState { func label() -> String { "Loading" } } // Three more tiny types for fixed screen states.Costly — Separate state types scatter the fixed cases; a reviewer must find each type to see all possible labels.
Closed alternatives are a useful design choice, too.
2.3 Keep variation out of callers. A boundary pays off when it removes repeated branching across features.
Checkout, returns, and admin repeat the postal versus express choice. A new carrier would require changing each copy of that branch.
Better example
protocol ShippingQuoting { func quote(_ package: Package) async throws -> Quote } struct Checkout { let shipping: ShippingQuoting func quote(_ package: Package) async throws -> Quote { try await shipping.quote(package) } }Better — Checkout depends on ShippingQuoting, so its quote flow stays the same when the app supplies a different carrier implementation.
Costly example
if carrier == .postal { postal.quote(package) } else if carrier == .express { express.quote(package) } // Repeated in checkout, returns, and admin.Costly — Each caller repeats the carrier switch and must be updated when another carrier is offered.
Look for repeated variation before introducing polymorphism.
3 / L
Liskov substitution
Keep every behavioral promise of the abstraction. A substitute must accept the inputs, deliver the outcomes, and honor the guarantees that callers rely on. Matching method signatures alone is insufficient.
Watch for: One implementation rejects a valid call or returns unexpectedly stale data.
3.1 Do not advertise unsupported behavior. A conformance must honor the operations its clients can call.
MetadataOnlyDocument has a read method that always reports unsupported. A caller cannot rely on the capability that method advertises.
Better example
protocol DocumentMetadata { var title: String { get } } protocol ReadableDocument: DocumentMetadata { func read() throws -> Data } struct MetadataOnlyDocument: DocumentMetadata { let title: String }Better — MetadataOnlyDocument exposes its metadata without a read method, so callers cannot ask it for data it does not have.
Costly example
protocol ReadableDocument { func read() throws -> Data } struct MetadataOnlyDocument: ReadableDocument { func read() throws -> Data { throw DocumentError.unsupported } }Costly — A ReadableDocument caller can invoke read on MetadataOnlyDocument and discover at runtime that reading was never supported.
Model the real capabilities instead of faking conformance.
3.2 Preserve valid inputs. An implementation should not add hidden preconditions to a shared contract.
ImageResizing does not state a 1024-pixel limit, but RemoteResizer rejects wider requests. A caller using a valid-looking size gets a provider-specific failure.
Better example
protocol ThumbnailResizing { // Contract: width and height are at most 1024 pixels. func thumbnailMax1024(_ image: Image, to size: Size) throws -> Image } struct RemoteThumbnailer: ThumbnailResizing { func thumbnailMax1024(_ image: Image, to size: Size) throws -> Image { /* … */ } }Better — The limit of 1024 is part of the advertised role, so callers can choose it intentionally.
Costly example
protocol ImageResizing { func resize(_ image: Image, to size: Size) throws -> Image } struct RemoteResizer: ImageResizing { func resize(_ image: Image, to size: Size) throws -> Image { guard size.width <= 1024 else { throw ResizeError.unsupported } /* … */ } }Costly — RemoteResizer adds a hidden width precondition to ImageResizing and can reject an image another resizer would accept.
State constraints in the shared contract, not in one surprise implementation.
3.3 Test the behavioral promise. Matching signatures are not enough when timing, errors, or freshness matter.
A loader can have the right method signature and still return an old profile after an update. Reading once to warm a cache, renaming the profile, and reading again makes that broken promise visible.
Better example
protocol ProfileLoading { /// A read reflects the latest committed update. func load(_ id: UUID) async throws -> Profile } func checkFreshRead(_ loader: ProfileLoading, store: ProfileStore, id: UUID) async throws { _ = try await loader.load(id) // Warm any cache. try await store.rename(id, to: "Ada") let updated = try await loader.load(id) assert(updated.name == "Ada") }Better — The shared check warms any cache, renames the profile, then requires the next load to return Ada for every loader.
Costly example
final class CachedProfileLoader: ProfileLoading { let store: ProfileStore var cache: [UUID: Profile] = [:] func load(_ id: UUID) async throws -> Profile { if let saved = cache[id] { return saved } // Old name after rename. let profile = try await store.load(id) cache[id] = profile return profile } }Costly — CachedProfileLoader returns its saved profile before checking the store, so the second load can return the old name and fail the check.
Write contract tests for the promises clients rely on.
4 / I
Interface segregation
Depend only on capabilities the client uses. Small, meaningful roles let clients and implementations state what they actually need. A client that needs two roles can compose them.
Watch for: An implementation adds no-op methods, unsupported errors, or fatalError.
4.1 Split capabilities by client need. A reader should not have to depend on writing or deleting.
InvoicePreview only loads invoices, but InvoiceRepository also requires saving and removal. A read-only preview adapter must implement operations it cannot use.
Better example
protocol InvoiceReading { func load(_ id: UUID) throws -> Invoice } protocol InvoiceWriting { func save(_ invoice: Invoice) throws } struct InvoicePreview { let invoices: InvoiceReading }Better — InvoicePreview uses a reader’s load operation, so its adapter needs no save or removal methods.
Costly example
protocol InvoiceRepository { func load(_ id: UUID) throws -> Invoice func save(_ invoice: Invoice) throws func delete(_ id: UUID) throws } // A read-only preview must implement all three.Costly — InvoiceRepository makes the preview depend on save and removal operations, even though it only loads invoices.
Shape roles around clients, not around one large provider.
4.2 Compose roles when a client needs both. Small capability roles can be combined without making every client depend on both.
InvoiceEditor needs load and save, but AllInvoiceOperations also requires CSV export and archive purging. Those extra methods expand its dependency for no editor behavior.
Better example
typealias InvoiceEditing = InvoiceReading & InvoiceWriting struct InvoiceEditor { let invoices: any InvoiceEditing } // Export and archive capabilities remain separate.Better — The editor receives only reading and writing capabilities, leaving CSV export and archive purging outside its dependency.
Costly example
protocol AllInvoiceOperations { func load(_ id: UUID) throws -> Invoice func save(_ invoice: Invoice) throws func exportCSV() throws -> Data func purgeArchive() throws }Costly — The broad invoice operations dependency includes export and archive methods, so unrelated API changes can reach editor code.
Compose a larger role at the client that truly needs it.
4.3 Avoid unsupported methods. A no-op or fatalError implementation usually signals a mismatched role.
BatchJob can run but cannot pause. Giving it a pause method makes an unsupported call look available until it throws or aborts.
Better example
protocol Runnable { func run() } protocol Pausable { func pause() } struct BatchJob: Runnable { func run() { /* … */ } } struct InteractiveJob: Runnable, Pausable { func run() { /* … */ } func pause() { /* … */ } }Better — BatchJob exposes only run; types that can actually pause expose that capability separately.
Costly example
protocol Worker { func run() func pause() } struct BatchJob: Worker { func run() { /* … */ } func pause() { fatalError("Cannot pause") } }Costly — A caller can invoke BatchJob.pause and get an error or crash instead of a paused job.
If an implementation cannot honor a method, revisit the role.
5 / D
Dependency inversion
Let policy define a role; supply details at the edge. Business rules should express their needs in domain terms. Construct concrete databases, network clients, and vendor adapters where the app is assembled.
Watch for: A domain type constructs a live SDK or carries vendor paths and payloads.
5.1 Inject infrastructure at the edge. Business policy should receive the collaborator it needs.
Checkout owns a LivePaymentGateway directly. Tests and alternate payment flows cannot supply another charger without changing Checkout.
Better example
protocol PaymentCharging { func charge(_ amount: Decimal) async throws } struct Checkout { let payments: PaymentCharging func pay(_ order: Order) async throws { try await payments.charge(order.total) } }Better — Checkout receives a PaymentCharging collaborator, so the app can supply a live or test charger for the same payment flow.
Costly example
struct Checkout { func pay(_ order: Order) async throws { let gateway = LivePaymentGateway() try await gateway.charge(order.total) } }Costly — Checkout is tied to LivePaymentGateway, making isolated tests and alternate chargers harder to provide.
Build concrete collaborators in the composition root.
5.2 Use domain language at the boundary. A policy-facing role should describe what the policy needs, not vendor mechanics.
The cloud SDK wrapper still exposes a JSON post and a path, so Checkout must know the vendor charge endpoint and payload format.
Better example
protocol PaymentCharging { func charge(_ amount: Decimal) async throws -> Receipt } struct VendorPaymentCharger: PaymentCharging { func charge(_ amount: Decimal) async throws -> Receipt { // Translate amount and receipt at the boundary. } }Better — PaymentCharging speaks in amounts and receipts; VendorPaymentCharger translates those values into vendor requests at the boundary.
Costly example
protocol CloudSDKWrapper { func postJSON(_ body: Data, path: String) async throws } // Checkout still knows "/v2/charges" and JSON fields.Costly — The JSON wrapper leaves endpoint paths and payload fields in Checkout, so vendor changes still reach business code.
Let the high-level use case define the abstraction.
5.3 Prefer a simple dependency when enough. A concrete value or closure can express a role without a protocol hierarchy.
ExpiryPolicy only needs the current date. A ClockFactoryProvider that creates another factory adds layers to obtain one value.
Better example
struct ExpiryPolicy { let now: () -> Date func isExpired(_ expiry: Date) -> Bool { expiry <= now() } } let policy = ExpiryPolicy(now: Date.init)Better — ExpiryPolicy receives a now function, so a caller can supply the real clock or a fixed test time directly.
Costly example
protocol ClockFactoryProvider { func makeClockFactory() -> ClockFactory } // Several layers exist only to read the current date.Costly — ClockFactoryProvider adds multiple factories to a single time lookup, making the dependency harder to trace.
Use the smallest boundary that expresses the dependency.
How the principles work together
Consider checkout: pricing, payment, and receipts have separate reasons to change (S). A payment provider can be added behind a charging role (O). Every provider must honor the same success and failure contract (L). Checkout depends on charging, not refund and reporting operations it never calls (I). The app supplies the concrete provider when it assembles checkout (D).
One useful boundary may support several principles. You do not need five layers to satisfy five initials.
Use judgment before adding a boundary
- Name the change that is difficult now or plausibly recurring.
- State what callers already rely on before moving behavior.
- Make the smallest change that localizes the variation or dependency.
- Test the contract, including failure and edge cases.
- Reassess the abstraction when new use cases arrive.