← Back to list

What Does Design Pattern Actually Do in Practice on Android? (Compose & Architectural Choices)

For Turkish, please follow this link

Enes Selçuk · 2026-05-26 12:16 · 0 claps · 9.2 min read
#android-development #android-design-patterns #android-design #kotlin #android-developers
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 🏛️ · Architecture

What Does Design Pattern Actually Do in Practice on Android? (Compose & Architectural Choices)

For Turkish, please follow this link

[embed]Android’de Design Pattern Pratikte Ne İşe Yarar? (Compose ve Architectural Seçimler) Akademik GoF Kalıpları vs. Production Gerçeklerimedium.com

Academic GoF Patterns vs. Production Realities

In the software world, the term design pattern typically brings the GoF (Gang of Four) catalog to mind: Strategy, Observer, Factory, and similar structures. However at the end of the day the real struggle in a production environment is far more raw and straightforward:

  • Where does the state live?
  • Where are side-effects triggered?
  • Where are the data boundaries drawn?
  • And most importantly, how does this structure remain testable?

In the Android ecosystem, the concept of a design pattern can no longer be considered on a single plane. In practice, we need to read this structure as three interleaved layers

  1. Macro architectural Patterns: MVVM, MVI/UDF adaptations, layering strategies, and module/feature boundaries.

  2. Platform & Jetpack Conventions: Standardized patterns provided by the ecosystem, such as Repository, Hilt (DI), Navigation, Paging, and WorkManager.

3. Functional Evolution of Classical Patterns: The streamlined version of traditional OOP patterns using modern practices. Problems we used to solve with interfaces and class hierarchies (like Strategy or Factory) are now solved much more succinctly with Kotlin lambdas, reified types, and Compose’s parametric component structure.

The goal of this article is not to create a new catalog of design patterns. Instead, we will focus on how to navigate those critical daily decision points with Jetpack Compose managing them correctly without falling into the over engineering trap.

Jetpack Compose Paradigms: Where Does the Discussion Change?

With Jetpack Compose, the UI operates on a runtime logic completely different from the classical View hierarchy: state changes, screen recomposition. This seemingly simple declarative statement directly ties our production architecture to three critical questions:

  1. Source of Truth: Where exactly should the state live?

  2. UI State Boundaries: Which states should remain ephemeral within the UI layer, and which should be tied to business logic?

  3. Execution Context: Which processes must run completely outside the UI runtime mechanism (I/O, timers, system APIs, navigation side-effects)?

A common mistake is seeing Compose merely as a new UI tool accidentally scattering state across components, or conversely, leaking domain decisions into the UI layer. In this new world, the concept of a design pattern transforms from a class hierarchy into a matter of discipline: UDF (Unidirectional Data Flow), immutability, state model boundaries, and managing side-effects with the correct primitives (LaunchedEffect, rememberUpdatedState, etc.).

Data Listing Strategies: From Adapter Comfort to Runtime Optimization

This paradigm shift is clearly visible in our daily development practices and performance decisions. In the classic View system, managing data set changes and optimizing list performance (e.g., DiffUtil operations) was largely a comfort hidden behind the RecyclerView.Adapter abstraction.

In the Compose world, this responsibility is delegated directly to the developer architecting the system and to properly feeding the Compose runtime mechanism.

Our focus is no longer on writing heavy boilerplate code, but on correctly managing these two critical mechanisms:

Correct key and contentType Strategies: Defining unique keys in components like LazyColumn or LazyRow to prevent unnecessary recomposition costs and avoid state loss.

Compiler-Level Stability: Ensuring our list models maintain data integrity, allowing the Compose compiler to mark them as @Stable or @Immutable, thereby optimizing the skipping mechanism.

In summary, list performance, once solved by a library abstraction, is now directly related to the design of our data models and the discipline of state separation.

Baseline: MVVM, But Saying “I Wrote MVVM” Isn’t Enough

In real projects, the architectural backbone is still largely shaped around the MVVM pattern: ViewModel + observable state + UI. However, with Compose, this classical model naturally and inevitably evolves into a UDF (Unidirectional Data Flow) cycle:

Event → ViewModel Business Logic → New Immutable UiState → UI Recomposition`

What’s critical here is not the name of the design pattern, but the preservation of this unidirectionality. Boundaries must be drawn very clearly:

UI Layer Responsibility: The UI should never be in a decision making position regarding business logic it should only render the state dictated to it.

ViewModel and Side Effect Management: The ViewModel should not be forced to know the navigation library or platform details in minute detail side-effects must be hidden behind the correct abstraction layers.

The Structural Wear Point of MVVM: State Design

The most frequent anti-pattern and source of architectural decay in MVVM architectures is:

  • ViewModels filled with hundreds of lines of spaghetti conditional blocks.
  • API/Network models leaking directly into the UI layer.
  • Complex screen states managed by scattered, interdependent boolean flags.

The issue isn’t just choosing an architectural label; it’s about designing the screen as a single state machine and preserving the atomic integrity of that UiState model.

MVI Paradigms: Is a Reducer Mandatory for Every Scenario?

In the industry, the statement “we use MVI” usually corresponds to one of two practical approaches:

  • A Deterministic Structure: A strict, mathematical Reducer mechanism with a centralized Single Source of Truth (SSOT).
  • A Pragmatic UDF: Event handler functions executed within the ViewModel, with state updates managed by Kotlin’s copy() method.

From a developer’s perspective, both approaches are valid. What’s truly critical is that the team speaks the same language regarding debugging and testability processes. To keep complexity manageable, clarifying responsibility boundaries with these three fundamental constructs is a practical solution:

  • UiState: An immutability-focused model representing all data to be rendered on the screen (including Loading, Empty, Error, Content variations).
  • UiEvent: An intent directed at the business logic, originating from user interactions or system triggers.
  • UiEffect (One-shot / Single-shot): Transient side-effects that shouldn’t be stored persistently in the UI state, such as showing a Snackbar, navigation routing, or single-shot dialog triggers.

The Single-shot Effect Paradox

UiEffect management is one of the grayest architectural areas. Some teams embed these transient events within UiState and reset the state after consumption in the UI layer. However, considering the Android lifecycle, the most stable balance is to truly manage one-shot events with a separate Channel or SharedFlow.

Otherwise, facing anti-patterns that lock down debugging operations such as duplicate re-triggering of the same side-effect during configuration changes or aggressive recomposition is inevitable.

At the end of the day, a project’s fate is determined not by a rigid pattern label but by a design discipline with clearly drawn architectural boundaries.

Side-Effect Management: The Professional Line in Compose

Side-effect management in Jetpack Compose projects often swings between two extremes:

  • Trying to blindly solve every asynchronous process with LaunchedEffect.
  • Worse, embedding a side-effect directly into the composition flow, inviting uncontrolled re-triggering.

For a stable and resource-efficient architecture in a production environment, position side-effect primitives with this discipline:

  • Consuming Data Flows (Stream Collection): The classic collectAsState() function continues to listen to the upstream flow even when the app goes to the background (STOPPED state), causing significant battery and CPU drain. Therefore, state flows in the UI layer must be collected using the lifecycle aware collectAsStateWithLifecycle().
  • One-shot Operations: When managing processes tied to screen entry or parameter changes with LaunchedEffect(key), choose the correct key strategy and avoid keyless I/O traps.
  • Disposable Resources: Use the DisposableEffect component, which provides cleanup blocks for structures requiring listener registration/unregistration.
  • Composition Tracking: Entrust light operations, like synchronizing platform components after every successful recomposition, to the SideEffect abstraction.

The Real Challenge: Idempotent Flow Design and Cancellation Discipline

The real difficulty here is not placing side-effect APIs into the code but designing idempotent flows that remain stable even after re-entry the Android lifecycle, and process death scenarios.

As a critical architectural nuance: the collectAsStateWithLifecycle() consumption strategy in the UI layer must work in sync with the stateIn(SharingStarted.WhileSubscribed(5000)) configuration in the ViewModel layer. If this 5-second tolerance window is not correctly configured, data flows in the background will inevitably be cancelled and restarted unnecessarily during configuration changes (like device rotation).

At this layer, the concept of a design pattern strips away rigid patterns and reduces to lifecycle-aware collection practices and correct cancellation discipline.

Repository Boundaries in the Data Layer: Abstraction, Data Consistency, and Real Responsibilities

Whether building a declarative Jetpack Compose architecture or a classic imperative View system, the bottleneck in scalable projects is not the UI layer it’s the management of data consistency and state synchronization.

In this context, the Repository pattern should not be seen merely as a cosmetic file naming rule or an empty interface abstraction. This layer is responsible for providing the following architectural guarantees to the project’s data strategy:

  • Single Source of Truth: Ensuring a single, deterministic entry point for data (Single Face of Data) for upper layers, and preventing data races or cache mismatches.
  • Data Semantics & Source Orchestration: Managing the data flow strategy (e.g., Offline-First approach) between Network (Retrofit/Ktor), Room, or In-memory Cache. Also, transforming platform-specific exceptions into independent Error Semantics that the Domain layer can understand.
  • Loose Coupling: Completely isolating upper layers from which data library or platform service is running in the background, and mapping data models with strict boundaries at this layer.

The “God Object” Trap: Transitioning from Repository to Use-Case Layer

The most common architectural anti pattern in the data layer is Repository classes gradually turning into massive God Objects where every API endpoint and data model is thrown. This leads to business logic inadvertently leaking into the data layer.

To prevent this architectural decay, the healthiest approach is to position the repository layer strictly as a RAW data gateway. Business logic should be delegated to vertically sliced, single-responsibility, small Use-Case / Interactor components that process this raw data.

At the end of the day, imposing the same level of abstraction (over-abstraction) on every project is a development error context is always critical in architectural decisions.

DI Boundaries in Modular Architecture: Dependency Graph, Scope Selection, and Encapsulation

When using Hilt in modern Android projects, the most valuable issues for a developer are not simply library setup or writing boilerplate modules. The real focus should be on object lifecycle management and answering these critical questions:

  • Scope Selection & Memory Management: Should this dependency truly be a global @Singleton, or should it be designed with a scope limited to the application’s lifetime (@ApplicationScope) but considering memory optimization?
  • State Retention Strategy: Where are screen-specific states retained? In the ViewModel across configuration cycles (@ActivityRetainedScoped), or directly in Compose’s own runtime memory?
  • Testability & Substitution: In unit or integration tests, which interfaces in the dependency graph can you easily substitute with test Fakes/Mocks?

DI Graph and Visibility Boundaries in Modular Structure

With the use of Compose Navigation, especially as the DI graph grows across feature modules, what solves complexity is not rigid architectural pattern designs, but establishing strict Public API Surfaces.

In large-scale multi-module projects, rather than chasing “fancy” architectures, package private visibility and encapsulating dependencies only within the modules that need them come into play. Architectural maintainability can be preserved as long as a module’s internal dependency graph remains inaccessible and invisible to other modules.

Lists and Performance in Jetpack Compose

Compose performance optimization depends not on abstract design patterns but directly on compiler-level decisions and runtime mechanisms. Maintaining list and screen performance in large-scale projects relies not on strict architectural rules, but on the disciplined application of this three-part development checklist:

  • Compiler-Level Stability (Stability & Immutability): Ensuring data models fed to the UI layer can be marked as stable (@Stable / @Immutable) by the Compose compiler. This exempts components with unchanged data from unnecessary recomposition processes.
  • Efficient List Diffing (Correct key and contentType Selection): Assigning a unique key to every item in components like LazyColumn or LazyRow to ensure only the changed item is recomposed when the data set updates, and to prevent state loss.
  • Derived State Management: Avoiding exposing high-frequency, continuously changing state flows (like scroll position) directly to the UI without filtering; using primitives like derivedStateOf to prevent a “recomposition storm” on every scroll movement.

The Golden Rule: Metric-Driven Optimization Over Rote Patterns

There is no single “correct design pattern” for performance. The true professional approach is to focus on concrete metrics rather than performing premature optimization that harms code readability.

It’s about monitoring recomposition counts with Layout Inspector, catching UI jank with Macrobenchmark/Microbenchmark tools, and striking the right balance between clean code standards and hardware limitations.

Anatomy of Large-Scale Projects: Common Anti-Patterns

Theoretical resources and beginner guides often focus on idealized scenarios. However, in large scale production environments, structural errors that increase technical debt are inevitable during code review processes. As project scale grows, the most common anti-patterns directly threatening architectural sustainability are:

  • God ViewModel: A single ViewModel class taking on all responsibility for the screen, including business logic and even data flow. The result: unit tests become impossible to write, dependencies become tangled, and Git diffs lead to unresolvable merge conflicts.
  • Business Rules and Formatting in the UI Layer: Embedding network calls, string formatting, or domain logic filters directly inside composable functions because “it was fast on my local machine.” As the project grows, this approach turns the codebase into an unmaintainable legacy pile.
  • Managing Request State with a Global Mutable Singleton: Trying to manage screen states via a globally defined, mutable singleton object accessible from everywhere. The Android operating system’s process death and configuration change behavior will sooner or later cause memory leaks and crashes.
  • Uncontrolled Navigation Decisions in the UI Layer: Managing navigation routes directly from within a composable at click time without using the UiEffect abstraction. While seemingly fine at first when it comes to back stack management, deep links, or state restoration scenarios, the architecture completely locks up.
  • Non Atomic Scattered State Design: Trying to manage screen state with dozens of independent boolean flags instead of a single holistic model. This leads to a management crisis where you can no longer track which flag is true or false and when.

Conclusion: Moving Beyond Design Patterns on Android

In the Android ecosystem the concept of a “design pattern” has a much more dynamic reality than memorized theoretical templates or interview flashcards. When Composable UI the Android Lifecycle, and data layers work together, any abstraction layer without clearly drawn boundaries quickly returns as technical debt in production.

The most stable architectural results achieved with Jetpack Compose come not from fancy over engineered reducer mechanisms but from:

  • Atomically modeling screen state.
  • Managing side effects in a lifecycle-aware manner.
  • Building a Single Source of Truth (SSOT) in the data layer.

메타데이터
post_id
b39d38438cea
slug
what-does-design-pattern-actually-do-in-practice-on-android-compose-architectural-choices-b39d38438cea
url
https://medium.com/@enesselcuk/what-does-design-pattern-actually-do-in-practice-on-android-compose-architectural-choices-b39d38438cea
canonical_url
https://medium.com/@enesselcuk/what-does-design-pattern-actually-do-in-practice-on-android-compose-architectural-choices-b39d38438cea
author_url
https://medium.com/@enesselcuk
status
ok
fetched_at
2026-06-09 15:37:30