DRY: every piece of knowledge has one authoritative place. KISS: pick the simplest design that solves today's problem. YAGNI: do not build for requirements you do not have yet. DRY backfires when you merge code that only looks alike but changes for different reasons.
DRY is about knowledge, not text: the API base URL, the password rules, a date format. When one changes, exactly one place should change. Similar lines with different owners are not a violation.
When DRY hurts: merging the sign-up and edit-profile validation because they match today ties two screens together, and the first difference becomes a flag. Wait for the third repetition before extracting; in tests, readable repeated setup beats a clever shared helper.
KISS in Swift: a struct and a function before a generic protocol hierarchy; @State and .task before a view model for a two-field screen.
YAGNI: no plugin system or protocol "for flexibility" until a second implementation or a test needs it. It is not a reason to skip what is cheap now and expensive later: accessibility labels, localizable strings, a version field in persisted data.
iOSMiddle
OOP, SOLID & Design Patterns
OOP and SOLID in Swift, DRY, KISS and YAGNI, coupling and cohesion, dependency injection, GoF patterns, delegate, target-action, observer and coordinator, anti-patterns
Questions closed
0 / 18
- 0Review
- 2Asked often
Questions
Cohesion is how strongly the parts of one type or module belong together; coupling is how much one type depends on another's details. Aim for high cohesion and low coupling, so a change lands in one place instead of rippling through others.
Cohesion is the measurable side of SRP: a type with one reason to change is cohesive. A view controller that formats prices, calls the API and decides navigation has three reasons, and its methods touch disjoint sets of properties: the usual sign that it is really three types. Utils and AppManager are low cohesion by name.
Kinds of coupling in iOS code: on a concrete type (NetworkManager.shared inside a method), on another object's internals (navigationController?.viewControllers.first as? HomeViewController), on call order (configure() before start()), and hidden coupling through a notification name or a UserDefaults key spelled by hand on both sides.
Loosening it: depend on a small protocol the caller owns, receive data through init instead of reaching for it, report events upward through a closure or a delegate.
A quick check: how many files a typical change touches, and how much of the app you must build to test one type.
A static let shared plus a private init(). Swift creates a static property lazily and exactly once, even when several threads reach it first. The problem is the design: a singleton is global state that any line can reach, so dependencies hide and tests leak into each other.
Safe creation, not safe use: static let goes through swift_once, so there is never a second instance, but the state inside still needs an actor, @MainActor or a lock. Swift 6 rejects a static let that holds a non-Sendable class for exactly that reason.
Strict singleton or shared instance: UIApplication.shared is the only instance allowed. URLSession.shared, UserDefaults.standard, FileManager.default and NotificationCenter.default are shared defaults: you can still create your own, which is what tests do.
Hidden dependencies and leaking tests: SessionStore.shared inside a method appears in no signature, and its state survives from one test to the next, so parallel Swift Testing runs see each other's writes.
When it is fine: something truly app-wide with little or no mutable state, such as a logger or the app's configuration. Even then, pass it in through init with .shared as the default, so a caller can replace it.
Declare a class-bound protocol for the events, give the delegating object a weak var delegate, and call it at the right moments. Cocoa's naming makes one delegate reusable and expressive: the sender comes first, should methods ask permission, will and did report before and after.
Sender first: tableView(_:didSelectRowAt:) passes the table, so one controller can be the delegate of two tables, tell them apart and read the sender's state.
should, will, did: textFieldShouldBeginEditing(_:) returns a Bool, and with false the field never starts editing; scrollViewWillBeginDragging(_:) and scrollViewDidScroll(_:) only report. A return value is what a delegate offers and a notification cannot: the delegate takes part in the decision.
**weak and AnyObject**: the owner already holds the delegating object (the controller holds its view), so a strong reference back would be a retain cycle, and weak needs a class-bound protocol.
Delegate vs KVO: delegation is one-to-one and the object decides what to tell; KVO lets any number of observers watch an @objc dynamic property without the object knowing about them.
Liskov substitution: callers of FeedLoader rely on its promise of exactly one completion, and CachedFeedLoader calls it twice. The checked continuation in feed(from:) then stops the app with SWIFT TASK CONTINUATION MISUSE: feed(from:) tried to resume its continuation more than once.
What Liskov asks: a conformer or subclass must keep the behaviour its callers rely on, not just the signatures. The compiler checks the types; the rest of the contract (how many callbacks, on which thread, which errors, whether it blocks) is checked only by the callers, at runtime.
Each caller breaks differently: a DispatchGroup that calls leave() per completion traps on the unbalanced leave; a screen appends the posts twice or flickers; an analytics event goes out twice. Swapping the remote loader for the cached one was supposed to be invisible.
The fix: keep FeedLoader single-shot. If the cache is a fallback, the loader answers once, from the network or, when that fails, from the cache. If the screen wants "cached first, then fresh", that is a different contract with its own type, for example one that returns an AsyncThrowingStream and may yield twice.
Catch it: one contract test run against every conformer: call load once and assert exactly one completion.
No client should depend on methods it does not use. In Swift that means small role protocols named after what one caller needs, combined with & where a caller needs two, instead of one UserService that every screen and every test double has to carry.
What a fat protocol costs: each test double implements every method, most as fatalError("unused"); a change to one requirement touches every conformer; and a view model's init no longer says what the screen actually uses.
Split by client, not by method: the profile header loads the user and uploads an avatar, so it asks for exactly those two roles. One protocol per method is the opposite mistake.
One class, many roles: LiveUserService conforms to all of them, so the split costs no extra implementation. typealias ProfileServices = CurrentUserLoading & AvatarUploading names a combination that recurs.
Defaults that hide it: an extension that gives block(_:) an empty body lets a conformer claim a capability it does not have, and callers get a silent no-op. A default implementation should be real shared behaviour, never a way to skip a requirement.
In the SDK: Codable is Encodable & Decodable, so a request body needs only Encodable; table views split the data source, the delegate and prefetching; generic algorithms ask for Sequence, not Array.
Dependency injection is a technique: an object receives its collaborators instead of creating them. Dependency inversion is a design rule: high-level and low-level code both depend on an abstraction that the high-level side owns. You can inject a concrete client and still have no inversion.
Injection without inversion: init(client: URLSessionAPIClient) is injected, but the Feed module still cannot build without Networking, and a test needs that concrete client.
Inversion without injection: a service locator that resolves FeedLoading at runtime inverts the dependency but hides it from the signature.
Who owns the protocol: FeedLoading lives in the Feed module and speaks the feed's language (latestPosts()), not HTTP's. Networking never imports Feed; a small adapter in the app target, the composition root, maps one onto the other. The arrow that pointed from Feed to Networking now points from the adapter to both.
The payoff: Feed builds and tests without Networking, and moving from REST to GraphQL changes only the adapter.
A smell: a protocol named after its implementation (URLSessionProtocol, NetworkManagerProtocol) is a test seam owned by the low-level side. Useful, but it is not inversion.
A factory method is one overridable method that creates an object, so a subclass or conformer decides the concrete type. An abstract factory is an object with several such methods that build a family of related objects, swapped as a whole: live screens for stub screens, for example.
Factory method in UIKit: UIView creates its backing layer by asking layerClass, so a subclass gets a CAGradientLayer or an AVPlayerLayer without UIView knowing either. loadView() plays the same role for a controller's root view.
Simple factories: what Swift code usually calls a factory is a static func make… or an initializer that picks the type for you. Not the GoF factory method, but the same goal: callers never name the concrete type.
Abstract factory in practice: a coordinator receives a CheckoutScreens factory that holds the dependencies and knows each screen's initializer, so the coordinator decides only the order. Tests and previews pass a factory that returns stub controllers; a white-label build passes another family.
Often just a closure: a list that receives makeDetail: (Item) -> UIViewController has a one-product factory with no protocol at all.
When not: a factory protocol that wraps a single init call adds a type and buys nothing.
Rarely in its classic form: default arguments and memberwise initializers already solve the telescoping-constructor problem a Java builder exists for. What remains in Swift is a configuration value you fill in and hand over, a fluent chain that returns modified copies, and result builders for DSLs.
Defaults first: a struct with default values gives Toast(message:duration:) and every other combination with no extra code, and the compiler still insists on the required fields.
Configuration values: UIButton.Configuration, UIListContentConfiguration, URLComponents and DateComponents are filled in step by step and then passed to the object that uses them: the builder's job (assemble first, construct once) done with a plain struct.
Fluent copies: methods that return a modified copy of a struct make .method("POST").header(...) chains readable and safe to share. SwiftUI modifiers follow the same idea.
When a real builder pays: assembly is spread over time or places (a multipart body, a query built from filters), or an invariant spans several fields and must be checked once, in a build() throws that rejects an end date before the start date.
Result builders: @ViewBuilder and RegexBuilder turn a block of statements into one value; worth writing for a DSL that should read like a document, not for a struct with five fields.
An adapter makes one existing type fit an interface your code expects; a facade puts one simple interface in front of a whole subsystem. Wrapping an SDK is usually both: a facade with the few calls the app needs, and adapter code behind it that converts the vendor's types, callbacks and errors.
An adapter in Swift is often one line: when the signatures already match, extension UserDefaults: SettingsStore {} is the whole adapter. When they do not, a wrapper converts: a delegate or a stream into async, vendor errors into yours, CLLocation into the coordinate the feature needs.
A facade: a CameraController with start(), capturePhoto() async throws -> Data and stop() hides AVCaptureSession, the device input, AVCapturePhotoOutput, the permission check and the session queue. Callers give up flexibility on purpose.
Why wrap an SDK at all: vendor types stop leaking into features, so a new vendor or a new major version touches one file; tests pass a fake that conforms to your protocol; and threading rules live in one place, which matters when the SDK calls back on arbitrary threads.
The trap: a wrapper that mirrors every vendor method one to one, with the vendor's types still in its signatures. It adds a file and protects nothing; shape the interface by how the app uses it.
A decorator conforms to the same protocol as the object it wraps and adds behaviour around each call, so caching, logging or retry stack per instance. A proxy has the same shape but another job: it controls access to the real object, creating it lazily, guarding it or forwarding across a boundary.
vs an extension: an extension adds new methods to every instance of a type at compile time. It cannot change what image(for:) does, cannot apply to one instance and not another, and cannot hold state such as a cache.
vs a subclass: a subclass fixes one combination at compile time, so caching plus logging plus retry becomes a class per combination, and structs and final classes cannot be subclassed at all. Decorators are combined at runtime, at the composition root.
Order is behaviour: a logging decorator outside the cache logs every request; inside it, only cache misses.
Proxies in iOS: a weak proxy as the target of a CADisplayLink or Timer, so the link no longer keeps the view alive; NSXPCConnection.remoteObjectProxy, which forwards calls to another process; a lazy proxy that creates an expensive SDK or database on first use.
SwiftUI: every modifier wraps a view in ModifiedContent, a decorator applied by value.
Keep the observers in a collection of weak references, such as NSHashTable.weakObjects(), and loop over a snapshot when something changes. The subject never keeps a screen alive, and an observer that has been deallocated simply drops out instead of receiving calls.
Why weak: the subject (a player, a session, a download manager) usually outlives the screens that observe it. A strong array keeps every screen that ever subscribed alive, still calling into views nobody sees.
Notify from a snapshot: an observer may unsubscribe, or subscribe another one, inside its callback; allObjects copies the table first, so the loop never walks a collection that is changing under it.
No veto: delivery order in a hash table is unspecified, and a should method with a return value makes no sense with many listeners. Decisions stay with a one-to-one delegate.
Ready-made alternatives: a Combine PassthroughSubject or @Published (each subscriber keeps its own AnyCancellable, so the subscription is the lifetime), Observation for state the UI reads, NotificationCenter for app-wide events. A single AsyncStream does not work here: two consumers split its elements between them.
A request travels along a chain of handlers until one handles it, and the sender never learns which one did. UIKit's responder chain is the textbook case: a cell sends a request upward, and the first view, controller or app object able to handle it does, with no delegate wiring.
The chain: a view's next is its superview, or its view controller if it is that controller's root view. A controller's next is the window if its view is the window's root, the presenting controller if it was presented, and otherwise the superview of its view. From the window it goes on to its window scene, UIApplication and the app delegate.
Built-in uses: unhandled touches and presses, UIKeyCommand shortcuts, edit-menu actions such as copy(_:) (canPerformAction(_:withSender:) decides who answers), and any action sent with a nil target, which starts at the first responder.
A typed walk in Swift: casting each next to a protocol, as in the code, needs no @objc selector and gives a compile-checked method signature.
The trade-off: the link is invisible. Nothing guarantees a handler, so a cell reused where no ancestor handles the action silently does nothing; assert in debug builds when none is found. For flows that must happen, an explicit closure or delegate is clearer.
Your own chains fit when several handlers may apply and their order is the policy, such as deep-link handlers asked in turn until one accepts the URL.
Strategy puts each variant of an algorithm behind one interface, and the caller picks or swaps the variant at runtime, so the code that uses it never switches over the variants. In Swift a strategy is a small protocol, or just a closure when one function is all it is.
In the SDK: JSONDecoder.dateDecodingStrategy and keyDecodingStrategy (with a .custom closure), sorted(by:), a compositional layout's section provider, SwiftUI's ButtonStyle, LabelStyle and FormatStyle. The component stays the same; the strategy decides how it decodes, sorts, lays out or looks.
Protocol or closure: a closure for one stateless function, like (Product, Product) -> Bool; a protocol when the strategy has several methods or configuration, needs a name in logs, or must be Equatable for tests.
Strategy vs State: the same structure with a different driver. The client chooses a strategy, and it rarely changes during the object's life; a state is replaced by the object itself as events arrive, and each state knows which states may follow it.
**When a switch is better**: a closed set of two or three variants that you own. An enum with a method is simpler, and adding a case makes the compiler point at every switch that must handle it.
stopTapped() never ends the buffering, and bufferingDidFinish() starts playback without checking what the user wants now. When the engine's callback arrives after Stop, it sets isPlaying = true and calls engine.start(): a late event overrides the user's last action.
Why it happens: the callback is an asynchronous event, but the code treats it as a command. Two independent Bools describe four combinations, and nothing in the type rules out the impossible ones, such as buffering and playing at once, or still buffering after Stop.
How the fix works: one State enum and a single send(_:) that switches over (state, event). bufferingFinished is accepted only in .buffering; in .idle it falls through to default and is ignored, so the engine is never started.
Carry identity in events: .bufferingFinished(url) must match the URL being buffered. Otherwise Stop, then Play on another video, lets the first video's late callback start the second one before it is ready.
Enum or State pattern objects: the enum is the Swift default: an exhaustive switch, value semantics, an Equatable state to assert in tests. A class per state (BufferingState, PlayingState) pays off only when each state carries a lot of behaviour of its own.
Catch it: a test that sends events in the hostile order (play, stop, buffering finished) and asserts that the state is .idle and a spy engine never received start().
Command turns a request into an object that can be stored, queued, logged or reversed later. UndoManager is built on it: each change registers a closure that reverses it, and the undo and redo stacks are simply stored commands.
Register the inverse at the moment of change: registerUndo(withTarget:handler:) stores the closure, and setActionName(_:) gives the menu its "Undo Add Shape" title.
Redo comes from the inverse registering too: whatever is registered while an undo runs goes onto the redo stack. Write remove so it registers add, and add so it registers remove; a handler that changes state directly, bypassing those methods, leaves Redo greyed out.
Name the step only outside an undo: called during an undo, remove(_:) would rename the redo step to "Remove Shape"; skipping setActionName while isUndoing keeps "Redo Add Shape".
Grouping: with groupsByEvent (the default) everything registered in one run-loop turn is one undo step; beginUndoGrouping() and endUndoGrouping() make a multi-step change undo as one.
Where it comes from: every UIResponder has an undoManager (shake to undo, the three-finger gestures), SwiftUI exposes @Environment(\.undoManager), and text views register their own edits.
Other commands in the SDK: UIAction is an action as a value, Operation is work as an object in a queue.
An abstraction is wrong when its callers must pass flags or special cases to get their own behaviour out of it. Back out by inlining it into each caller, deleting what that caller never uses, and only then extracting what is genuinely shared: duplication is cheaper than the wrong abstraction.
How it happens
Two pieces of code look alike on day one and get merged. Requirements diverge, and each new caller adds a parameter instead of questioning the merge: isProfile, mode: .compact, a closure "for customisation".
Signs
Boolean or mode parameters that select behaviour, and branches only one caller ever reaches.
A change for one screen breaks another, and a bug fix needs a new flag so that it does not affect the others.
A base class whose subclasses override methods to do nothing, or a generic ListViewController<Item, Cell> configured through ten closures.
Backing out
Pin the behaviour first: snapshot tests for every screen that uses the component.
Inline it: give each caller its own copy, then delete from each copy every branch that caller never takes. The copies are usually far smaller than the original.
Extract again from the copies only what is the same and changes for the same reason, usually a small leaf such as a thumbnail or a price formatter, not the whole row.
Move one caller per pull request, so each review stays readable.
Preventing it
Extract mechanics (layout primitives, formatting) rather than policy (what a row shows on which screen), and compose small views instead of configuring one big one: a design-system component with a few named variants beats one with eight flags.
God objects, singletons used as hidden dependencies and a deep BaseViewController are the first three I look for. Each hides dependencies or couples unrelated features, and each has an incremental way out that needs no rewrite: move one responsibility at a time behind a seam.
God object
Sign: an AppManager, NetworkManager or Utils with hundreds of methods that every feature imports and edits; merge conflicts gather there.
Way out: list which features call which methods, move each group behind a role protocol into the module that uses it, and keep the old type as a facade that forwards until the last call site has moved.
Singletons everywhere
Sign: .shared inside method bodies, a launch that works only because of the order things happen to be touched, tests that pass alone and fail together.
Way out: a protocol over each singleton, injected with .shared as the default argument; later real instances from the composition root, with state scoped to a session or a flow instead of the process.
BaseViewController
Sign: a base class that started with a spinner and now holds analytics, keyboard handling, error alerts and a hook for one feature; every screen pays for all of it.
Way out: composition: small helpers (KeyboardAvoider, ErrorPresenter), child view controllers, protocol extensions constrained to UIViewController, view modifiers in SwiftUI. Move one responsibility out, delete it from the base, repeat.
Start with what blocks the work in front of you, the file you have to change this sprint, not with the worst file in the repository.