Skip to the content.

Configuration-Driven Architecture: App, Routing, and Screen Configuration

How three related patterns—config-driven app setup, router-based navigation, and builder-style screen configuration—enable a flexible, testable, and customizable Android architecture.


Introduction

In a large, multi-journey fintech or investment app (e.g. portfolio management, order placement, trading), you often need to:

Three patterns work together to achieve this:

  1. Configuration-driven app architecture – App-level setup is driven by configuration objects
  2. Router pattern for navigation – Navigation is configurable: abstract interfaces + injectable implementations
  3. Builder pattern for screen configuration – Screens and components are configured via builders with sensible defaults

This post explains how these patterns fit together and how to implement them.


1. Configuration-Driven App Architecture

What It Is

Instead of hardcoding app setup, you define a configuration object that describes how the app should behave. The app reads this configuration at startup and wires everything accordingly.

Structure

Application
    └── createApplicationConfiguration()
            └── ApplicationConfiguration { ... }
                    ├── sdkConfiguration
                    ├── networkingConfiguration
                    ├── journeyConfigurationsDefinitions
                    ├── pushNotificationConfiguration
                    └── featureFlags

Each journey (feature module) has its own configuration definition. The app composes them into a single configuration tree.

Example: DSL-Style Configuration

// Application
override fun createApplicationConfiguration() = ApplicationConfiguration {
    pushNotificationConfiguration = PushNotificationConfiguration { ... }
    featureFlags += listOf(EnableAdvisoryService)
}

// Journey configuration definitions
fun JourneyConfigurationsDefinitions(initializer: Builder.() -> Unit) =
    JourneyConfigurationsDefinitions.Builder().apply(initializer).build()

Example: Journey-Level Configuration

// Each journey has a configuration
orderPlacementConfigurationDefinition = {
    OrderPlacementConfiguration { }
}

portfolioDashboardConfigurationDefinition = {
    PortfolioDashboardConfiguration { }
}

// Nested configuration
portfolioReportingConfigurationDefinition = {
    PortfolioReportingConfiguration {
        portfolioReportingScreenConfiguration = PortfolioReportingScreenConfiguration {
            overviewTabScreenConfiguration = OverviewScreenConfiguration {
                holdingsCardConfiguration = HoldingsCardConfiguration {
                    itemIsInteractive = true
                }
            }
        }
    }
}

Benefits


2. Router Pattern for Navigation (Config for Routing)

What It Is

Navigation is treated as configuration: instead of screens calling findNavController().navigate() directly, they use a router interface. The router is injected and can be swapped per environment or test.

Why Routers Are “Config for Routing”

Structure

Screen
    └── injected OrderSummaryRouter
            └── onOrderPlacementSuccess()  →  navigate to success
            └── onOrderPlacementFailure(code)  →  navigate to error

DefaultOrderSummaryRouter(navController)
    └── implements OrderSummaryRouter

Example: Router Interface

interface OrderSummaryRouter {

    fun onOrderPlacementSuccess()

    fun onOrderPlacementFailure(code: String?)
}

interface OrderInputRouter {

    fun onOrderDraftSuccess()

    fun onOrderDraftFailure(code: String?)
}

Example: Default Implementation

class DefaultOrderSummaryRouter(
    private val navController: NavController
) : OrderSummaryRouter {

    override fun onOrderPlacementSuccess() {
        navController.navigate(R.id.action_summary_to_success)
    }

    override fun onOrderPlacementFailure(code: String?) {
        navController.navigate(
            R.id.action_summary_to_setup_error,
            code?.let { bundleOf(OrderSetupErrorScreen.ARG_KEY_CODE to it) }
        )
    }
}

Example: Injection (Router as Config)

// DI module
factory<OrderSummaryRouter> { (navController: NavController) ->
    DefaultOrderSummaryRouter(navController)
}

// Screen
private val screenRouter by scoped<OrderSummaryRouter> { 
    parametersOf(findNavController()) 
}

// Usage
viewModel.placeOrderAction.collect { placement ->
    when (placement) {
        is State.Data -> screenRouter.onOrderPlacementSuccess()
        is State.Error -> screenRouter.onOrderPlacementFailure(placement.error.code)
        else -> { }
    }
}

Benefits


3. Builder Pattern for Screen Configuration

What It Is

Each screen (or component) has a configuration class that holds all customizable aspects: titles, icons, labels, error messages, etc. A Builder provides defaults; consumers override only what they need.

Deferred Resources

To support theming and late resolution, use deferred resources (DeferredText, DeferredDrawable) instead of raw strings:

Structure

Screen
    └── injected ScreenConfiguration
            ├── title: DeferredText
            ├── description: DeferredText
            ├── applyChangesButtonText: DeferredText
            ├── errorDialogConfiguration: EdgeCaseDialogConfiguration
            └── portfolioSelectionConfiguration: PortfolioSelectionConfiguration

Example: Screen Configuration with Builder

class CustomizePortfolioScreenConfiguration(
    val navigateUpIcon: DeferredDrawable,
    val navigateUpContentDescription: DeferredText,
    val title: DeferredText,
    val description: DeferredText,
    val applyChangesButtonText: DeferredText,
    val errorDialogConfiguration: EdgeCaseDialogConfiguration,
    val componentsConfiguration: CustomizePortfolioComponentConfiguration,
    val portfolioSelectionConfiguration: PortfolioSelectionConfiguration,
) {

    class Builder {

        var navigateUpIcon: DeferredDrawable =
            DeferredDrawable.Attribute(R.attr.iconClose)

        var title: DeferredText =
            DeferredText.Resource(R.string.dashboard_customize_title)

        var description: DeferredText =
            DeferredText.Resource(R.string.dashboard_customize_description)

        var applyChangesButtonText: DeferredText =
            DeferredText.Resource(R.string.dashboard_customize_apply_button)

        var errorDialogConfiguration: EdgeCaseDialogConfiguration =
            EdgeCaseDialogConfiguration { ... }

        fun build(): CustomizePortfolioScreenConfiguration { ... }
    }
}

// DSL
fun CustomizePortfolioScreenConfiguration(
    block: CustomizePortfolioScreenConfiguration.Builder.() -> Unit
): CustomizePortfolioScreenConfiguration =
    CustomizePortfolioScreenConfiguration.Builder().apply(block).build()

Example: Function-Based Configuration

class OrderSummaryScreenConfiguration private constructor(
    val orderDurationItemTitle: (String) -> String,
    val orderDurationItemSubtitle: (String, LocalDate?) -> String
) {

    class Builder(...) : OrderPlacementConfigurationBuilder() {

        var orderDurationItemTitle: (String) -> String = { code ->
            when (code) {
                "DAY" -> context.getString(R.string.order_duration_title_day)
                "GTD" -> context.getString(R.string.order_duration_title_gtd)
                "GTC" -> context.getString(R.string.order_duration_title_gtc)
                else -> context.getString(R.string.order_duration_title_fallback, code)
            }
        }

        var orderDurationItemSubtitle: (String, LocalDate?) -> String = { code, date ->
            when (code) {
                "DAY" -> context.getString(R.string.order_duration_subtitle_day)
                "GTD" if date != null -> context.getString(..., formattedDate)
                else -> ...
            }
        }
    }
}

Example: Menu Configuration (Config + Routing)

// Settings menu uses config for both content AND routing
SettingsConfiguration.Builder()
    .apply {
        sections = listOf(
            MenuSection(
                title = DeferredText.Resource(R.string.app_settings_section_security),
                items = listOf(
                    MenuItem(
                        title = DeferredText.Resource(R.string.app_settings_item_logout),
                        icon = DeferredDrawable.Resource(R.drawable.ic_logout) { ... }
                    ) {
                        OnActionComplete.NavigateTo(R.id.action_main_to_login,
                            bundleOf(LOG_OUT_ACTION to true)
                        )
                    }
                )
            )
        )
    }
    .build()

Here, OnActionComplete.NavigateTo is the routing config: the destination is defined in the menu config, not in the screen code.

Benefits


4. How They Work Together

+-----------------------------------------------------------------------------+
| Application Configuration                                                   |
| ApplicationConfiguration {                                                  |
|   journeyConfigurationsDefinitions = JourneyConfigurationsDefinitions {     |
|     orderPlacementConfig = { OrderPlacementConfiguration {} }               |
|   }                                                                         |
| }                                                                           |
+-----------------------------------------------------------------------------+
                                     |
                                     v
+-----------------------------------------------------------------------------+
| Journey Configuration (OrderPlacementConfiguration)                         |
|   - orderSetupScreenConfiguration                                           |
|   - orderSummaryScreenConfiguration (Builder pattern)                       |
|   - transactionDetailsScreenConfiguration                                   |
|   - genericErrorBody: (String) -> String                                    |
+-----------------------------------------------------------------------------+
                                     |
                                     v
+-----------------------------------------------------------------------------+
| Screen                                                                      |
|   - Injects journeyConfig (screen configuration)                            |
|   - Injects router (navigation config)                                       |
|   - Uses config for UI: journeyConfig.orderSummaryScreenConfiguration       |
|   - Uses router for navigation: screenRouter.onOrderPlacementSuccess()      |
+-----------------------------------------------------------------------------+

Flow

  1. App startupcreateApplicationConfiguration() builds the app config
  2. Journey registration – Each journey config is registered (e.g. OrderPlacementConfiguration)
  3. Screen creation – Screens receive config and router via DI
  4. UI rendering – Screens use config for titles, labels, error messages
  5. User actions – Screens call router methods; router performs navigation

Override Points

Level What to override
App createApplicationConfiguration(), createUseCaseDefinitions()
Journey journeyConfigurationsDefinitions in app config
Screen orderSetupScreenConfiguration, orderSummaryScreenConfiguration, etc.
Component HoldingsCardConfiguration, PortfolioSelectionConfiguration
Router orderPlacementRouterDefinition (replace default with custom)

5. Implementation Checklist

For App Configuration

For Router Pattern

For Screen Configuration


6. Benefits and Pitfalls

Benefits

Benefit Description
Customization Consumers override only what they need
Testability Config and routers are injectable; tests use minimal config
Consistency Same pattern across app, journey, screen, and component
Theming Deferred resources resolve at runtime
Decoupling Screens don’t depend on NavController or concrete IDs

Pitfalls

  1. Config sprawl – Too many configs; keep hierarchy shallow and focused
  2. Builder boilerplate – Use codegen or shared base builders if it grows
  3. Router proliferation – One router per flow is enough; avoid per-screen routers when unnecessary
  4. Default fatigue – Ensure defaults are sensible so most consumers don’t need to override

7. Summary

Three patterns work together:

Together they enable a flexible, testable, and customizable architecture where consumers override only what they need.