← Back to list

5 Must-Know Android Design Patterns (Real Use Cases)

Writing robust, scalable, and maintainable Android applications is a craft. A significant part of mastering this craft lies in…

Prakash Sharma · 2025-05-28 16:29 · 6 claps · 10.3 min read paywalled
#android-design-patterns #android #builder-pattern #android-app-development #androiddev
Open on Medium ↗
Wiki topics: 🛠️ · Crafts & DIY

5 Must-Know Android Design Patterns (Real Use Cases)

Writing robust, scalable, and maintainable Android applications is a craft. A significant part of mastering this craft lies in understanding and effectively applying software design patterns. These aren’t off-the-shelf libraries but proven, reusable blueprints for solving common software design problems. For Android developers looking to elevate their code quality and efficiency, a solid grasp of design patterns is indispensable.

This guide dives into 5 essential design patterns frequently encountered and highly beneficial in Android development. We’ll explore them with practical Kotlin examples and highlight their real-world applications within the Android ecosystem, helping you write cleaner, more professional code.

1. Builder Pattern

Core Idea:

The Builder pattern is a creational pattern designed to construct complex objects step-by-step. It separates the object’s construction logic from its representation, allowing the same construction process to create varied representations. This is particularly useful when an object has many constructor parameters, especially if several are optional or require specific configurations.

Why It Shines in Android (Real Use Cases):

The Builder pattern simplifies the creation of objects with numerous configuration options, enhancing code readability and maintainability.

  • Android SDK Staples: You’re already using it!
AlertDialog.Builder: AlertDialog.Builder(context)
                    .setTitle("Confirm")
                    .setMessage("Are you sure?")
                    .setPositiveButton("Yes"){...}
                    .create()
  • NotificationCompat.Builder: Constructing complex user notifications.
  • Snackbar.make(): While not a strict builder, it follows a similar fluent configuration style.
  • Networking & Databases:
  • Retrofit.Builder(): Configuring your Retrofit instance with base URLs, converters, and call adapters.
  • Room.databaseBuilder(): Setting up your Room database with context, name, and migrations.
  • Custom Objects: Building complex configuration objects for custom views, Intent objects with many extras, or data models with numerous optional fields.

Kotlin’s features like named arguments and default parameters can simplify simple builders. For more complex objects, a dedicated builder class, often enhanced with DSL-like syntax, is powerful.


data class NetworkRequest(
    val url: String,
    val method: String = "GET",
    val headers: Map<String, String> = emptyMap(),
    val body: Any? = null,
    val timeoutMs: Int = 10000,
    val retries: Int = 0
)

class NetworkRequestBuilder(private val url: String) {
    private var method: String = "GET"
    private var headers: MutableMap<String, String> = mutableMapOf()
    private var body: Any? = null
    private var timeoutMs: Int = 10000
    private var retries: Int = 0
    fun method(method: String) = apply { this.method = method }
    fun addHeader(key: String, value: String) = apply { this.headers[key] = value }
    fun body(body: Any?) = apply { this.body = body }
    fun timeout(ms: Int) = apply { this.timeoutMs = ms }
    fun retries(count: Int) = apply { this.retries = count }
    fun build(): NetworkRequest = NetworkRequest(url, method, headers.toMap(), body, timeoutMs, retries)
}

fun main() {
    val postRequest = NetworkRequestBuilder("https://api.example.com/users")
        .method("POST")
        .addHeader("Authorization", "Bearer token123")
        .addHeader("Content-Type", "application/json")
        .body("""{ "name": "Kotlin Dev" }""")
        .timeout(5000)
        .retries(3)
        .build()
    println(postRequest)
    val getRequest = NetworkRequestBuilder("https://api.example.com/data").build() // Uses defaults
    println(getRequest)
}

Key Advantages:

  • Readability: Object creation is descriptive and easy to follow.
  • Flexibility: Handles multiple optional parameters gracefully.
  • Immutability: The final constructed object can be immutable.
  • Avoids Telescoping Constructors: Eliminates the need for numerous constructor overloads.

2. Singleton Pattern

Core Idea:

The Singleton pattern is a creational pattern that ensures a class has only one instance throughout the application and provides a global point of access to it.

Why It Shines in Android (Real Use Cases):

Singletons are vital for managing resources or services that should be shared and unique across the app.

  • Shared Resources:
  • Networking Clients: A single OkHttpClient or Retrofit instance configured once and reused for all network calls.
  • Databases: A single RoomDatabase instance to manage all database operations.
  • SharedPreferences Wrappers: A utility class providing a single point of access to SharedPreferences.
  • Global Services:
  • Analytics trackers, In-app purchase managers, Image loading libraries (e.g., Glide, Coil often manage their core components as singletons).
  • Dependency Injection: Frameworks like Hilt and Dagger often manage dependencies with singleton scope.

Kotlin Code Example:

Kotlin makes creating thread-safe singletons trivial using the object declaration.


object ApiConfigManager {
    val apiKey: String
    val baseUrl: String
    init {
        println("ApiConfigManager initialized.")
        apiKey = "YOUR_API_KEY_HERE_LOAD_SECURELY"
        baseUrl = "https://api.myapp.com/v1/"
    }
    fun getAuthenticatedUrl(endpoint: String): String {
        return "$baseUrl$endpoint?apiKey=$apiKey"
    }
}

object AppCache {
    private val cache = mutableMapOf<String, Any>()
    init {
        println("AppCache initialized.")
    }
    fun put(key: String, value: Any) {
        cache[key] = value
    }

    fun <T> get(key: String): T? {
        return cache[key] as? T
    }
    fun clear() {
        cache.clear()
    }
}

fun main() {
    println("Accessing API Config:")
    val userUrl = ApiConfigManager.getAuthenticatedUrl("users")
    println("User URL: $userUrl")
    val anotherConfigAccess = ApiConfigManager.baseUrl
    println("\nUsing AppCache:")
    AppCache.put("userId", 123)
    val userId: Int? = AppCache.get("userId")
    println("Cached userId: $userId")
}

Key Advantages:

  • Controlled Instantiation: Guarantees a single instance.
  • Global Access: Provides a well-defined access point.
  • Resource Efficiency: Prevents multiple initializations of costly resources.
  • Conciseness in Kotlin: object handles thread-safety and lazy initialization (initialization happens on first access).

Caveats:

  • Can introduce global state, making unit testing harder if dependencies are not injectable.
  • May violate the Single Responsibility Principle if the singleton accrues too many responsibilities.

3. Factory Method Pattern

Core Idea:

The Factory Method is a creational pattern that defines an interface (or abstract class) for creating an object, but lets subclasses decide which class to instantiate. It allows a class to defer instantiation to its subclasses, promoting loose coupling.

Why It Shines in Android (Real Use Cases):

This pattern is useful when the exact type of object to be created isn’t known beforehand or needs to vary.

  • ViewModel Creation: ViewModelProvider.Factory is a classic example. You implement create() to provide specific ViewModel instances, often with constructor dependencies.
  • Fragment Instantiation: FragmentFactory (introduced in AndroidX Fragment 1.2.0) allows centralized and testable fragment creation, especially useful with dependency injection.
  • RecyclerView ViewHolders: In RecyclerView.Adapter, the onCreateViewHolder(parent: ViewGroup, viewType: Int) method acts as a factory. Based on viewType, you create and return different ViewHolder instances for different item layouts.
  • Dynamic UI Components: Creating different types of dialogs, custom views, or data parsers based on runtime conditions or configurations.
  • Themed Resources: Creating drawable or view instances based on the current app theme.

Example:


interface AppNotification {
    fun getType(): String
    fun display(title: String, message: String)
}

class ToastNotification(private val context: Any /* Mock Android Context */) : AppNotification {
    override fun getType(): String = "Toast"
    override fun display(title: String, message: String) {
        println("[$context] TOAST: $message") 
    }
}
class DialogNotification(private val context: Any /* Mock Android Context */) : AppNotification {
    override fun getType(): String = "Dialog"
    override fun display(title: String, message: String) {
        println("[$context] DIALOG: Title: '$title', Message: '$message'") // Simulate AlertDialog
    }
}
class SnackbarNotification(private val rootView: Any /* Mock Android View */) : AppNotification {
    override fun getType(): String = "Snackbar"
    override fun display(title: String, message: String) {
        println("[$rootView] SNACKBAR: $message (Action: $title)") // Simulate Snackbar.make()
    }
}

// Creator (Abstract Factory)
abstract class NotificationCreator(protected val androidContext: Any) {
    // The Factory Method
    abstract fun createNotification(): AppNotification

    fun showNotification(title: String, message: String) {
        val notification = createNotification()
        notification.display(title, message)
    }
}
// Concrete Creators
class ToastNotificationCreator(context: Any) : NotificationCreator(context) {
    override fun createNotification(): AppNotification = ToastNotification(androidContext)
}
class DialogNotificationCreator(context: Any) : NotificationCreator(context) {
    override fun createNotification(): AppNotification = DialogNotification(androidContext)
}
// Client code
fun showUserMessage(factory: NotificationCreator, title: String, message: String) {
    factory.showNotification(title, message)
}

fun main() {
    val mockActivityContext = "MockActivityContext"
    val mockRootView = "MockRootView"
    val toastCreator = ToastNotificationCreator(mockActivityContext)
    showUserMessage(toastCreator, "Update", "Profile updated successfully!")
    val dialogCreator = DialogNotificationCreator(mockActivityContext)
    showUserMessage(dialogCreator, "Error", "Failed to save settings.")
    // Example of a simple factory (not strictly Factory Method, but related)
    // for Snackbar which might need a specific view.
    val snackbarCreator = object : NotificationCreator(mockRootView) {
        override fun createNotification(): AppNotification = SnackbarNotification(androidContext)
    }
    showUserMessage(snackbarCreator, "Retry", "Network connection lost.")
}

Key Advantages:

  • Flexibility: Easily introduce new product types without altering client code that uses the creator’s interface.
  • Decoupling: Client code interacts with the abstract product and creator, not concrete implementations.
  • Encapsulation: Centralizes object creation logic within specific factory methods or classes.

4. Observer Pattern

Core Idea:

The Observer pattern is a behavioral pattern where an object (the subject or observable) maintains a list of dependents (its observers) and notifies them automatically of any state changes, typically by invoking one of their methods.

Why It Shines in Android (Real Use Cases):

This pattern is the backbone of reactive programming and event handling in Android, enabling UI updates in response to data changes.

  • Jetpack Architecture Components:
  • LiveData: UI controllers (Activities/Fragments) observe LiveData objects in ViewModels. When the data changes, the UI updates automatically. LiveData is lifecycle-aware, preventing memory leaks.
  • StateFlow and SharedFlow: Kotlin Coroutines-based alternatives to LiveData, offering more powerful and flexible stream processing capabilities, also widely used for observing state changes.
  • Event Listeners: Fundamental to Android UI.
  • View.OnClickListener, TextView.addTextChangedListener(), RecyclerView.addOnScrollListener() are all implementations where UI elements (subjects) notify listeners (observers) of events.
  • BroadcastReceivers: System-wide or app-specific events (e.g., battery low, network change, custom broadcasts) are observed by registered BroadcastReceivers.
  • RxJava/RxKotlin: Extensively uses Observables (subjects) and Observers/Subscribers for reactive programming.

Kotlin Code Example (Simplified LiveData-like mechanism):


fun interface DataObserver<T> {
    fun onChanged(data: T)
}

// A simple version of a LiveData-like class
class MutableObservableData<T>(initialValue: T? = null) {
    private val observers = mutableListOf<DataObserver<T>>()
    private var _value: T? = initialValue
    var value: T?
        get() = _value
        set(newValue) {
            if (_value != newValue) {
                _value = newValue
                _value?.let { data -> // Notify only if data is not null
                    observers.forEach { it.onChanged(data) }
                }
            }
        }
    fun observe(observer: DataObserver<T>) {
        observers.add(observer)
    }

    fun removeObserver(observer: DataObserver<T>) {
        observers.remove(observer)
    }
}

class UserViewModel {
    val userName = MutableObservableData<String>()
    val userScore = MutableObservableData<Int>()
    fun fetchUserData() {
        // Simulate fetching data
        println("ViewModel: Fetching user data...")
        userName.value = "AndroidFan123" // Triggers observers
        userScore.value = 1000          // Triggers observers
    }
    fun updateUserScore(newScore: Int) {
        println("ViewModel: Updating score...")
        userScore.value = newScore      // Triggers observers
    }
}
// Mock Activity
class UserProfileActivity {
    private val viewModel = UserViewModel() // In real Android, injected or obtained via ViewModelProvider
    private val nameObserver = DataObserver<String> { name ->
        println("Activity UI: User name updated to: $name")
    }

    private val scoreObserver = DataObserver<Int> { score ->
        println("Activity UI: User score updated to: $score")
    }

    fun onCreate() {
        println("Activity: onCreate called.")
        viewModel.userName.observe(nameObserver)
        viewModel.userScore.observe(scoreObserver)
        viewModel.fetchUserData()
    }

    fun simulateScoreUpdate() {
        viewModel.updateUserScore((viewModel.userScore.value ?: 0) + 50)
    }

    fun onDestroy() { 
        viewModel.userName.removeObserver(nameObserver)
        viewModel.userScore.removeObserver(scoreObserver)
    }
}

fun main() {
    val activity = UserProfileActivity()
    activity.onCreate()
    println("\n--- Simulating user action ---")
    activity.simulateScoreUpdate()
    println("\n--- Simulating configuration change (ViewModel survives, Activity recreates) ---")
    // In a real scenario, activity would be destroyed and recreated.
    // Observers would be re-attached in the new activity's onCreate.
    // For this demo, let's just show the viewModel retains state.
    activity.onDestroy() // Old activity instance cleans up
    val newActivityInstance = UserProfileActivity()
    newActivityInstance.onCreate() // New activity instance observes, gets current data
    println("\n--- ViewModel data is still available ---")
    println("ViewModel's current name: ${newActivityInstance.viewModel.userName.value}")
    println("ViewModel's current score: ${newActivityInstance.viewModel.userScore.value}")
}

Key Advantages:

  • Loose Coupling: The subject doesn’t need to know concrete details about its observers, only that they implement the observer interface.
  • Dynamic Relationships: Observers can be added or removed at runtime.
  • Event-Driven Architecture: Excellent for building UIs that react to data changes or other events.
  • Lifecycle Awareness (with Jetpack components): Prevents common issues like memory leaks and updating UI when it’s not visible.

5. Template Method Pattern

Core Idea:

The Template Method is a behavioral pattern that defines the skeleton of an algorithm in a superclass but allows subclasses to override specific steps (or “hooks”) of the algorithm without changing its overall structure.

Why It Shines in Android (Real Use Cases):

This pattern promotes code reuse for common workflows while allowing customization for specific parts.

  • Base Activity/Fragment Classes: A common Android practice.
  • A BaseActivity might define a template for UI setup: setupToolbar(), initializeViewModel(), observeLiveData(), bindViews(). Subclasses then implement the specifics of each step.
abstract class BaseNetworkActivity : AppCompatActivity() { 
  fun fetchData() { 
    showLoading(); 
    val data = performNetworkRequest(); 
    hideLoading(); processData(data); 
  } 

  protected abstract fun performNetworkRequest(): Result<MyData>; 
  protected abstract fun processData(data: Result<MyData>); /* ... */ 
}
  • RecyclerView Adapters:
  • The RecyclerView.Adapter itself can be seen as a template. The overall process of creating and binding views is defined, but onCreateViewHolder() and onBindViewHolder() are abstract/overridden by subclasses to provide specific view creation and data binding logic for different item types.
  • Custom View Drawing: A base custom view class might handle common measurement and layout logic, while onDraw() is a template step for subclasses to implement custom rendering.
  • Data Processing Pipelines: Defining a sequence of steps for processing data (e.g., fetch, parse, transform, save), where some steps are common and others are specialized.
  • Android Framework: Many Android framework classes use this pattern internally. For instance, Activity lifecycle methods (onCreate, onStart, onResume, etc.) are hooks within a larger, predefined operational flow managed by the system.

Example:

// Abstract class defining the template method and abstract/hook steps
abstract class DataProcessor<T, R> {
// The template method: Defines the algorithm's skeleton
    fun process(): R {
        onPreProcess() // Hook
        val rawData = loadData()
        val validatedData = validateData(rawData)
        if (!validatedData.isValid) {
            handleValidationError(validatedData.error)
            throw IllegalStateException("Data validation failed: ${validatedData.error}")
        }
        val processedData = transformData(validatedData.data)
        saveProcessedData(processedData)
        onPostProcess(processedData) // Hook
        return processedData
    }
    // Abstract steps: Must be implemented by subclasses
    protected abstract fun loadData(): T
    protected abstract fun transformData(data: T): R
    protected abstract fun saveProcessedData(data: R)
    // Concrete step (can be overridden if `open`)
    protected open fun validateData(data: T): ValidationResult<T> {
        println("Performing generic data validation...")
        // Default: assume data is valid
        return ValidationResult(data, true, null)
    }
    // Hook methods: Subclasses can override these optional steps
    protected open fun onPreProcess() {
        println("DataProcessor: Starting process...")
    }
    protected open fun onPostProcess(result: R) {
        println("DataProcessor: Process completed with result: $result")
    }
    protected open fun handleValidationError(error: String?) {
        println("DataProcessor: Validation Error: $error")
    }
    data class ValidationResult<T>(val data: T, val isValid: Boolean, val error: String?)
}
// Concrete subclass: Processing user profile data (simulated)
data class UserProfileData(val id: Int, val name: String, val email: String?, val age: Int)
data class ProcessedUserProfile(val id: Int, val displayName: String, val isAdult: Boolean)
class UserProfileProcessor(private val userId: Int) : DataProcessor<UserProfileData, ProcessedUserProfile>() {
    override fun onPreProcess() {
        super.onPreProcess()
        println("UserProfileProcessor: Preparing to process profile for user ID: $userId")
    }
    override fun loadData(): UserProfileData {
        println("UserProfileProcessor: Loading profile for user ID: $userId from database/API...")
        // Simulate data loading
        return UserProfileData(userId, "Test User", "test@example.com", 25)
    }
    override fun validateData(data: UserProfileData): ValidationResult<UserProfileData> {
        println("UserProfileProcessor: Validating user profile data...")
        if (data.name.isBlank()) {
            return ValidationResult(data, false, "Name cannot be blank.")
        }
        if (data.email != null && !data.email.contains("@")) {
            return ValidationResult(data, false, "Invalid email format.")
        }
        return ValidationResult(data, true, null)
    }
    override fun transformData(data: UserProfileData): ProcessedUserProfile {
        println("UserProfileProcessor: Transforming user data...")
        val displayName = data.name.uppercase()
        val isAdult = data.age >= 18
        return ProcessedUserProfile(data.id, displayName, isAdult)
    }
    override fun saveProcessedData(data: ProcessedUserProfile) {
        println("UserProfileProcessor: Saving processed profile: $data to cache/database...")
    }
    override fun onPostProcess(result: ProcessedUserProfile) {
        super.onPostProcess(result)
        println("UserProfileProcessor: User profile processing finished successfully.")
    }
}

fun main() {
    println("--- Processing User Profile (Valid) ---")
    val userProcessor = UserProfileProcessor(1)
    try {
        val processedProfile = userProcessor.process()
        println("Final Processed Profile: $processedProfile")
    } catch (e: IllegalStateException) {
        println("Caught exception: ${e.message}")
    }

    println("\n--- Processing User Profile (Invalid Name) ---")
    // Simulate loading invalid data for demonstration
    val invalidUserProcessor = object : UserProfileProcessor(2) {
        override fun loadData(): UserProfileData {
            return UserProfileData(2, "", "invalid@example.com", 30) // Blank name
        }
    }
    try {
        invalidUserProcessor.process()
    } catch (e: IllegalStateException) {
        println("Caught expected exception during invalid processing: ${e.message}")
    }
}

Key Advantages:

  • Code Reusability: Centralizes the invariant parts of an algorithm in a superclass.
  • Extensibility: Subclasses can customize specific algorithm steps.
  • Framework for Algorithms: Provides a defined structure, ensuring subclasses adhere to the overall flow.
  • Inversion of Control (Hollywood Principle): The base class calls methods on the subclass, not the other way around.

Conclusion: Level Up Your Android Code

Mastering these five design patterns — Builder, Singleton, Factory Method, Observer, and Template Method — will significantly enhance your ability to write clean, organized, and maintainable Android applications in Kotlin. They provide structured solutions to common problems, fostering better architecture and collaboration.

While these are foundational, the world of design patterns is vast. Continue exploring and practicing their application. The key is not to force a pattern but to recognize when a problem’s characteristics align with a pattern’s intent.

What design patterns do you find most indispensable in your Android projects? Share your experiences and favorite use cases in the comments below!

Clap and subscribe to show your support.


메타데이터
post_id
989e00f6f347
slug
5-must-know-android-design-patterns-real-use-cases-989e00f6f347
url
https://medium.com/@trricho/5-must-know-android-design-patterns-real-use-cases-989e00f6f347
canonical_url
https://medium.com/@trricho/5-must-know-android-design-patterns-real-use-cases-989e00f6f347
author_url
https://medium.com/@trricho
status
ok
fetched_at
2026-06-09 15:37:30