DRY says each piece of knowledge has one home, KISS says pick the simplest design that works, and YAGNI says 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 (Don't Repeat Yourself) is about knowledge, not characters: a validation rule, a price formula or the API base URL should live in one place, so a change cannot miss a copy.
KISS (Keep It Simple): a when over three cases beats a plugin registry; every extra layer is one more place for a bug.
YAGNI (You Aren't Gonna Need It): no CacheStrategy interface "for later", no generic BaseRepository<T, ID> for one entity. Add the abstraction when the second real case arrives and shows what actually varies.
When DRY hurts: sign-up and profile edit validate a name with similar code, someone merges them into one function, and it grows an isSignUp flag, then a second one. That was accidental duplication: the same code today, different reasons to change. Two small functions were cheaper.
AndroidMiddle
OOP, SOLID & Design Patterns
OOP and SOLID in Kotlin, DRY, KISS and YAGNI, coupling and cohesion, dependency injection, GoF patterns and where Android and its libraries use them, anti-patterns
Questions closed
0 / 19
- 0Review
Questions
Coupling is how much one class depends on how another works; cohesion is how closely the parts of one class belong together. Low coupling lets you change a class without editing its neighbours, high cohesion gives each class one clear job.
Tight coupling on Android: a ViewModel that builds its own Retrofit and reads SharedPreferences by key knows the insides of two other things. Replacing the HTTP client or the storage edits the ViewModel, and a test cannot swap either.
Loosening it: depend on a narrow interface such as UserRepository, receive it through the constructor, and pass plain models across the boundary rather than Retrofit DTOs or Room entities.
Low cohesion: an AppUtils object with formatDate, hideKeyboard and md5, or a UserManager that signs in, caches avatars and sends analytics. Its members share nothing but the file, so half the app depends on it and it changes for every reason.
High cohesion: DateFormatter, KeyboardExt.kt, AvatarCache: small units whose members work on the same data.
Observer lets an object (the subject) notify any number of dependents when it changes, without knowing who they are. On Android that is every listener and callback, LiveData, and Flow, where collecting is subscribing and cancelling the coroutine is unsubscribing.
Classic form: a subject keeps a list and offers addListener/removeListener: View.OnClickListener, SensorEventListener, SharedPreferences.OnSharedPreferenceChangeListener. In Kotlin the listener is a function type or a fun interface.
**LiveData**: an observable holder that knows the observer's lifecycle: it delivers only while the owner is STARTED and removes the observer at DESTROYED.
**Flow and StateFlow**: a subscription is a running collect. It ends when its scope is cancelled, so repeatOnLifecycle or collectAsStateWithLifecycle() ties it to the screen.
The subject's thread: observers are called synchronously on the thread that changed the subject, so a listener fed from a background thread must switch to main before touching views.
The rule that breaks most often: every subscribe needs an unsubscribe in the mirrored lifecycle callback (onStart with onStop). A listener left in a long-lived subject keeps the Activity alive and fires into a dead screen.
Strategy puts each variant of an algorithm behind one interface, so the caller picks or swaps it at runtime instead of branching. In Kotlin a stateless one-method strategy is often just a function type, and Comparator or TimeInterpolator are strategies you already pass around.
On the platform: sortedWith(comparator), an animator's TimeInterpolator, RecyclerView's LayoutManager and ItemAnimator, the Arrangement you pass to a Compose Row. The container stays the same; the plugged-in object changes the behaviour.
Function type: (Long) -> Long for a discount rule. A lambda or a function reference is the strategy, no class needed.
**fun interface: when the strategy deserves a name in the API or Java callers implement it.
A full interface**: when it has several methods or carries configuration, such as a RetryPolicy with shouldRetry and delayMs.
Who chooses: the strategy is injected or selected outside (a setting, a feature flag, the user's plan). Each strategy knows nothing about the others, so adding one does not touch the caller or the existing ones.
It prints 20 and then 16. Square breaks the Liskov substitution principle: code written against Rectangle relies on width and height changing independently, and a Square passed in silently breaks that.
What Liskov demands: a subtype keeps the parent's contract, not just its signatures. It may not accept less (stronger preconditions), promise less (weaker postconditions), break the parent's invariants, or throw new exceptions.
Here: Rectangle implicitly promises that setting height leaves width alone. Square keeps every signature and breaks the promise, so resize gets the wrong area with no error. The compiler checks types, never contracts.
Warning signs: an override that throws UnsupportedOperationException or does nothing, or a caller that needs if (shape is Square).
The fix: do not make one a subtype of the other. Model both as immutable values behind a read-only abstraction (sealed interface Shape { val area: Int }); "change the width" becomes copy(width = 5), which a square never has to honour.
No client should depend on methods it does not use: split a fat interface into small role interfaces, which one class can still implement together. Default methods only spare implementers the empty overrides; callers still depend on the whole interface.
The listener symptom: Animator.AnimatorListener has four callbacks and ViewPager.OnPageChangeListener three, so code that needs one writes empty bodies. The Java answer was a base class with empty methods (AnimatorListenerAdapter, ViewPager2.OnPageChangeCallback), which uses up your only superclass.
Kotlin at the call site: core-ktx's doOnEnd { } and doAfterTextChanged { } register the full listener and expose only the callback you asked for.
Default methods: DefaultLifecycleObserver gives every lifecycle callback an empty body, so you override only onStart. That is fine when all the methods are events of one thing. It does not fix an interface that mixes unrelated roles: the client still sees them all and recompiles when any of them changes.
In your own code: a UserRepository with twenty methods injected into a screen that only reads the profile. Split it by role (ProfileReader, ProfileEditor, AvatarUploader); one implementation can implement all three, and DI binds each interface to it.
What it buys: a test fake implements one method instead of twenty. Split along the roles real clients have; one interface per method is its own smell.
No. An interface earns its place where the implementation must be swappable: a fake in tests, another platform, a module boundary that must not see the real class. An IUserRepository mirrored by one UserRepositoryImpl that nothing ever replaces is ceremony that doubles files and navigation.
When one implementation still deserves an interface
Slow or side-effecting dependencies in tests: network, database, clock, payments. A fake PaymentGateway is simpler and more stable than mocking a concrete class.
A module or layer boundary: :feature:checkout compiles against CartRepository from :core:domain while the implementation lives in :core:data, so the feature cannot reach the real class and the domain declares what it needs (dependency inversion).
When it is ceremony
Mappers, formatters, validators and use cases with pure logic: test the real class, it is fast and deterministic.
The I prefix and the Impl suffix are the tell: when the implementation has no better name than the interface, there is only one way to do the job.
"So it can be mocked" is no longer a reason: MockK mocks final Kotlin classes, and Mockito 5 uses the inline mock maker by default.
YAGNI applies: with constructor injection, extracting an interface later is a one-minute IDE refactoring, so wait for the second implementation or for the test that needs it.
A factory function hides how one object is built behind a name; Factory Method lets a subclass decide what to create by overriding a method the framework calls; Abstract Factory is one object that creates a whole family of related objects that must match each other.
Factory function: Toast.makeText, Intent.createChooser, a companion fun from(dto). Kotlin also has factory functions named like a type: MutableStateFlow(0), Job(), Channel() and CoroutineScope(...) are functions, so they can validate arguments and return a hidden implementation class, which a constructor cannot.
Factory Method: the framework owns the lifecycle and calls your override to get the object. RecyclerView.Adapter.onCreateViewHolder picks the holder class per view type; FragmentFactory.instantiate and ViewModelProvider.Factory.create are how constructor arguments reach fragments and ViewModels that the system recreates.
Abstract Factory: Retrofit's Converter.Factory creates the request-body, response-body and string converters for one format; swap Gson for Moshi or kotlinx.serialization and the whole set changes together.
FragmentFactory has to be set before super.onCreate(), because that call already recreates the fragments being restored.
A Builder still pays off when an object is configured step by step in different places, when build() must validate options together, when you derive variants from an existing object, and when a library API must stay binary compatible; OkHttp and Retrofit hit all four.
Step-by-step configuration: an OkHttpClient.Builder collects interceptors in a loop, gets HttpLoggingInterceptor only in debug builds and is passed to a function that adds certificate pinning. A constructor call needs every value ready on one line.
Validation at the end: Retrofit.Builder().build() throws IllegalStateException("Base URL required."). The builder is the natural place for rules like "this option requires that one".
Deriving: client.newBuilder() starts from a configured client and shares its connection pool and dispatcher, so a variant with a longer timeout is cheap. copy() on a data class is the Kotlin analogue for plain values.
Binary compatibility: adding a parameter with a default value to a published constructor changes its JVM signature, and already compiled callers fail with NoSuchMethodError. A new builder method is a pure addition. Kotlin's guidelines for library authors advise against data class in a public API for the same reason.
OkHttpClient.Builder is mutable: addInterceptor adds to base itself and returns the same builder, so base.build() later creates a second client that also carries AuthInterceptor, and every image request sends Authorization to the CDN.
Why it happens: fluent builder methods are written as apply { interceptors += interceptor } and return this, not a copy. val apiClient = base.addInterceptor(...) reads like branching off, but both names point to one builder, and each build() snapshots what it holds at that moment.
Why nothing fails: both clients work and the CDN ignores the header. Only a proxy log or a security review shows the token on a host you do not control.
The fix: build the shared client once and derive variants with baseClient.newBuilder(), which copies the client's settings (interceptors included) into a fresh builder and leaves the original untouched. The derived client reuses the same connection pool and dispatcher, so it costs almost nothing.
Catch it next time: a MockWebServer test on the CDN client asserting that takeRequest().getHeader("Authorization") is null. In review, a builder kept in a variable and built twice is a red flag.
By intent: an Adapter changes the interface so incompatible code fits together, a Decorator keeps the interface and adds behaviour, a Proxy keeps the interface and controls access to the real object, and a Facade puts one simpler interface in front of a whole subsystem.
Adapter: a callback API turned into a suspend function with suspendCancellableCoroutine, a listener turned into a Flow with callbackFlow, Retrofit's CallAdapter turning Call<T> into the return type you declared. The client sees an interface the wrapped object never had.
**RecyclerView.Adapter**: only loosely. It adapts your data to what the widget asks for (item count, view type, create and bind a holder), but it is really a data source that RecyclerView calls back, and a factory for view holders.
Decorator: same interface, extra behaviour, stackable: ContextThemeWrapper around a Context, a LoggingRepo : Repo by inner, BufferedInputStream around a stream.
Proxy: same interface, but it stands in for the real object and controls access to it: Retrofit's java.lang.reflect.Proxy behind your API interface (a local stand-in for the remote server), a lazy wrapper, a cache or a permission check in front of a service. The caller cannot tell it is not the real object.
Facade: a new, smaller interface over many objects: Glide.with(view).load(url).into(imageView) hides caches, decoders and threads; a repository hides Retrofit, Room and the merge policy.
Flyweight shares one heavy state between many light objects; Object Pool reuses mutable objects instead of allocating new ones. Drawables from one resource share their state, so mutate() must come before changing one, and Message.obtain() takes a recycled message from a pool.
Flyweight: drawable constant state
Every drawable loaded from the same resource shares one Drawable.ConstantState (for a BitmapDrawable, the bitmap and its paint). Change alpha or the tint on one, and every icon drawn from that resource changes. Textbook Flyweight state is immutable; this one is not, which is why mutate() exists.
mutate() gives that instance its own copy of the state; it cannot be undone, and after it the change stays local.
Object Pool (not a GoF pattern): Message.obtain()
Message.obtain() and handler.obtainMessage() take a message from a global pool of up to 50, and post uses the pool too; the Looper puts the message back after dispatching it.
The price is a lifetime rule: once sent, the message belongs to the queue and is reused later. Keeping a reference or sending the same instance twice is a bug, and MessageQueue throws an IllegalStateException that ends with "This message is already in use."
Writing your own pool
ART's garbage collector makes short-lived allocations cheap. A pool pays off only for objects that are expensive to create (bitmaps reused through BitmapFactory.Options.inBitmap, large buffers) or allocated per frame in onDraw or a touch handler; otherwise it adds reset bugs and keeps memory alive.
A request travels along an ordered list of handlers and each one either handles it or passes it on, so the sender does not know who answers. OkHttp interceptors and touch dispatch are the platform versions; a list of DeepLinkHandlers tried in order is one you write yourself.
Two flavours: "first match wins", where a handler that answers stops the chain (touch events, where onTouchEvent returning true consumes the gesture; deep-link routing). And the pipeline, where every handler wraps the next and may change the request on the way in and the response on the way out (OkHttp's chain.proceed, Ktor plugins).
Why use it: a new route or a new cross-cutting step is a new handler, not another branch in a when that every team edits, and each handler is tested on its own.
Order is behaviour: a catch-all handler placed first swallows everything behind it. Build the list in one place and keep the order explicit.
Decide what the end means: an unhandled request falls back to something visible (the home screen, an error), never a silent no-op.
With DI: Hilt multibindings (@IntoSet) let each feature module contribute its own handler. Do not rely on the iteration order of a multibound Set; give handlers a priority and sort.
Template Method fixes the steps of an algorithm in a base class and lets subclasses fill in hooks. Activity is one: the framework runs its own work around your onCreate, so skipping super crashes. Jetpack now passes behaviour in as callbacks and observers instead of asking you to override.
Where Android uses it: Activity and Fragment callbacks, View.measure() and draw() calling your onMeasure and onDraw.
The contract you inherit: the base class does real work inside super.onCreate() (restoring fragments and saved state, dispatching ON_CREATE). Skip it and the framework throws SuperNotCalledException: ... did not call through to super.onCreate(); @CallSuper makes it a Lint error first. An onMeasure that never calls setMeasuredDimension throws IllegalStateException.
Why it ages badly: it uses your only superclass, the subclass depends on when and in what order the base calls the hooks, and unrelated concerns pile into the same override, like one onActivityResult switching on five request codes.
What replaced it: the Activity Result API registers one callback per launcher instead of onActivityResult; OnBackPressedCallback replaces overriding onBackPressed() (deprecated in API 33); DefaultLifecycleObserver lets a component react to onStart without the Activity calling it.
Command turns a request into an object that can be stored, queued, logged or reversed. A Runnable posted to a Handler, a WorkRequest and an MVI action are commands, and undo is a stack of commands that each know how to revert themselves.
What the object buys you: the code that decides what should happen is separated from the code that does it and from when it happens. The request can wait in a queue (WorkManager even persists WorkRequests in its database), cross a boundary, be logged or be replayed.
MVI actions: a sealed interface CartAction passed to onAction is a set of commands without the executor attached; the ViewModel interprets them, which gives one place to log and test every user intent.
Undo and redo: each command implements execute and undo. Running one pushes it onto the undo stack and clears redo; undo pops, reverts and pushes onto redo. Store only what reverting needs (the removed text and its position), not a copy of the whole document.
Pitfalls: a queued command that captures an Activity or a View leaks it until it runs, and a command that must survive process death needs serializable data, not object references.
Make each state a sealed subtype that carries only its own data, and put every transition in one function (State, Event) -> State that the compiler checks exhaustively. State differs from Strategy in who switches: a state machine moves itself on events, a strategy is chosen from outside.
States carry their own data: Sending(file, attempt) has an attempt counter, Failed has a reason, Idle has nothing, so impossible mixes such as a URL on a failed upload cannot be built.
One transition function: nested whens make each allowed transition explicit, and an event that makes no sense in the current state returns the state unchanged, such as a second tap on Send while uploading.
**GoF State or a when**: the classic pattern gives each state class its own onEvent, so behaviour lives with the state. In Kotlin one central pure function is usually clearer and is unit-tested with no mocks; move methods into the states only when each has a lot of behaviour.
State versus Strategy: the same class diagram, a different intent. An upload state decides the next state by itself; a shipping-cost strategy is picked by the caller and never replaces itself.
On Android: hold the current state in a MutableStateFlow and change it with update { reduce(it, event) }, so two events arriving together cannot interleave. Keep reduce pure: update may run it again if another thread wins the race.
An interface whose methods each type implements is open to new types and closed to new operations; a sealed hierarchy with when is the reverse, since a new operation is just a new function and a new subtype breaks every when at compile time. Choose by which axis changes more often.
The trade-off
Interface: a PaymentProvider from another module is one more class and nothing existing changes, the textbook Open/Closed case. But a new operation, such as a refund policy, edits every implementation, often in modules owned by other teams.
**Sealed type with when**: operations live outside the types (message(), analyticsEvent()), and adding one touches no subtype. Adding a subtype is a compile error at every exhaustive when, which is the safety net you want when the set of cases is fixed.
The rule: a closed set of cases with an open set of operations calls for sealed (screen states, results, events, a parser's tree). An open set of cases contributed by plugins or other modules calls for an interface (payment providers, analytics backends, deep-link handlers). Sealed subtypes must live in the same module and package, so a feature module cannot add one at all.
Keep the check alive: an else branch on a sealed type silently absorbs the subtype added later.
Why Visitor is rare in Kotlin
Visitor exists to add operations to a fixed hierarchy in a language without exhaustive matching: every type implements accept(visitor), and every operation is a visitor class with one visit per type (double dispatch).
when with smart casts over a sealed type gives the same dispatch and the same exhaustiveness check without accept methods or visitor classes.
Visitor still appears where the hierarchy is huge, deep or not yours: KSP's KSVisitor, Lint's UAST visitors.
Every level adds behaviour all screens inherit whether they need it or not, the super call order becomes hidden coupling, and one change can break any screen. Move each concern into something a screen opts into: a lifecycle observer, an extension function, a ViewModel or a shared composable.
What goes wrong
Everything for everyone: the base shows a loading dialog, checks the session, tracks analytics, handles the keyboard and registers a network receiver, so a settings screen pays for all of it.
Hidden ordering: BaseAnalyticsActivity.onResume() reads a field that BaseAuthActivity.onCreate() sets. A subclass that calls super at a different point breaks a screen far away: the fragile base class problem.
One superclass only: a screen that must extend a library's Activity, or a Fragment that needs the same behaviour, cannot join the chain, so the code is copied.
Untestable: the behaviour only runs inside a real Activity.
Refactoring, one concern at a time
List what each level does and which screens really use it.
Lifecycle-bound work (session check, screen tracking, receivers) becomes a DefaultLifecycleObserver that the screen adds in one line. A concern that truly applies to every Activity goes into Application.ActivityLifecycleCallbacks, with no inheritance at all.
Stateless helpers such as hideKeyboard() become extension functions on Activity or Fragment.
Shared state (loading, errors) moves into ViewModels and a reusable UI component.
Delete each level once nothing uses it. In a single-Activity Compose app the base class disappears: behaviour lives in composables and ViewModels.
The usual suspects are the god Activity, Utils and Manager dumping grounds, global mutable singletons and copy-pasted logic. Refactor where changes and crashes concentrate, behind characterization tests, one small move per commit, never as a rewrite.
What to look for
God Activity or Fragment: two thousand lines of networking, parsing, caching, navigation and formatting. Every feature edits it, so merge conflicts and regressions cluster there.
**Utils, Helper, Manager: names that say nothing about the job collect unrelated functions. Low cohesion, and everything depends on them.
Global mutable state**: object Session { var user: User? } or a companion object holding a Context. Hidden dependencies, leaks, and tests that pass alone but fail together.
Copy-paste with drift: the same validation in three screens, each slightly different, so a fix lands in only one.
Speculative generality: an event bus, a generic BaseRepository<T> or a plugin system built for one use.
How to refactor
Measure first: churn from git log and the top crashes in Crashlytics show where the pain is. Ugly but stable code can wait.
Pin the behaviour: characterization tests around the logic you are about to move. Extracted pure functions get JVM tests; UI gets screenshot tests.
Extract, do not rewrite: move logic out of the Activity into a ViewModel and a repository; split Utils by caller into extension functions next to where they are used; replace a global object with an injected dependency and let the old object delegate to it until the last caller is gone (the strangler approach).
Lock it in: Detekt rules such as LargeClass and TooManyFunctions, module boundaries that make the old dependency impossible, and review.