Achieving Mobile App Architecture Goals: Create Exceptional, Testable, and Independent Apps

Table of Contents

Mobile app architecture is one of the most crucial aspects of app development. It’s like building a house; if your foundation is shaky, no matter how fancy the decorations are, the house will collapse eventually. In this blog post, We’ll discuss the mobile app architecture goals, with an emphasis on creating systems that are independent of frameworks, user interfaces (UI), databases, and external systems—while remaining easily testable.

Why Mobile App Architecture Matters

Imagine building a chair out of spaghetti noodles. Sure, it might hold up for a minute, but eventually, it’ll crumble.

Mobile app architecture is the thing that prevents our app from turning into a noodle chair.

A well-structured architecture gives our app:

  • Scalability: It can handle more users, data, or features without falling apart.
  • Maintainability: Updates, debugging, and improvements are easy to implement.
  • Testability: You can test components in isolation, without worrying about dependencies like databases, APIs, or third-party services.
  • Reusability: Common features can be reused across different apps or parts of the same app.
  • Separation of Concerns: This keeps things neat and organized by dividing your code into separate components, each with a specific responsibility. (Nobody likes spaghetti code!)

Let’s break down how we can achieve these goals.

The Core Mobile App Architecture Goals

To achieve an optimal mobile application architecture, we developers should aim for the following goals:

  • Independence from Frameworks
  • Independence of User Interface (UI)
  • Independence from Databases
  • Independence from External Systems
  • Independently Testable Components

Let’s look at them one by one.

Independence from Frameworks

You might be tempted to tightly couple your app’s architecture with a particular framework because, let’s face it, frameworks are super convenient. But frameworks are like fashion trends—today it’s skinny jeans, tomorrow, it’s wide-leg pants. Who knows what’s next? The key to a long-lasting mobile app architecture is to ensure it’s not overly dependent on any one framework.

When we say an architecture should be independent of frameworks, we mean the core functionality of the app shouldn’t rely on specific libraries or frameworks. Instead, frameworks should be viewed as tools that serve business needs. This independence allows business use cases to remain flexible and not restricted by the limitations of a particular library.

Why is this important?

  • Frameworks can become outdated or obsolete, and replacing them could require rebuilding your entire app.
  • Frameworks often impose restrictions or force you to structure your app in certain ways, limiting flexibility.

How to achieve framework independence?

Separate your business logic (the core functionality of your app) from the framework-specific code. Think of your app like a car: the engine (your business logic) should function independently of whether you’re using a stick shift or automatic transmission (the framework).

Example:

Imagine your app calculates taxes. The logic for calculating tax should reside in your business layer, completely isolated from how it’s presented (UI) or how the app communicates with the network.

Kotlin
class TaxCalculator {
    fun calculateTax(amount: Double, rate: Double): Double {
        return amount * rate
    }
}

This tax calculation has nothing to do with your UI framework (like SwiftUI for iOS or Jetpack Compose for Android). It can work anywhere because it’s self-contained.

Independence of User Interface (UI)

A well-designed architecture allows the UI to change independently from the rest of the system. This means the underlying business logic stays intact even if the presentation layer undergoes significant changes. For example, if you switch your app from an MVP (Model-View-Presenter) architecture to MVVM (Model-View-ViewModel), the business rules shouldn’t be affected.

Your app’s UI is like the icing on a cake, but the cake itself should taste good with or without the icing. By separating your app’s logic from the UI, you make your code more reusable and testable.

Why does UI independence matter?

  • UIs tend to change more frequently than business logic.
  • It allows you to test business logic without needing a polished front-end.
  • You can reuse the same logic for different interfaces: mobile, web, voice, or even a smart toaster (yes, they exist!).

How to achieve UI independence?

Create a layer between your business logic and the UI, often called a “Presentation Layer” or “ViewModel.” This layer interacts with your business logic and converts it into something your UI can display.

Example:

Let’s revisit our TaxCalculator example. The UI should only handle displaying the tax result, not calculating it.

Kotlin
class TaxViewModel(private val calculator: TaxCalculator) {

    fun getTax(amount: Double, rate: Double): String {
        val tax = calculator.calculateTax(amount, rate)
        return "The calculated tax is: $tax"
    }
}

Here, the TaxViewModel is responsible for preparing the data for the UI. If your boss suddenly wants the tax displayed as an emoji (💰), you can change that in the TaxViewModel without touching the core calculation logic.

Independence from the Database

Databases are like refrigerators. They store all your precious data (your milk and leftovers). But just like you wouldn’t glue your fridge to the kitchen floor (hopefully!), you shouldn’t tie your business logic directly to a specific database. Someday you might want to switch from SQL to NoSQL or even a cloud storage solution.

Independence from databases is a crucial goal in mobile application architecture. Business logic should not be tightly coupled with the database technology, allowing developers to swap out database solutions with minimal friction. For instance, transitioning from SQLite to Realm or using Room ORM instead of a custom DAO layer should not affect the core business rules.

Why does database independence matter?

  • Databases may change over time as your app scales or business requirements evolve.
  • Separating logic from the database makes testing easier. You don’t need to run a real database to verify that your tax calculations work.

How to achieve database independence?

Use a repository pattern or an abstraction layer to hide the details of how data is stored and retrieved.

Kotlin
class TaxRepository(private val database: Database) {

    fun saveTaxRecord(record: TaxRecord) {
        database.insert(record)
    }

    fun fetchTaxRecords(): List<TaxRecord> {
        return database.queryAll()
    }
}

In this case, you can swap out the database object for a real database, a mock database, or even a file. Your business logic won’t care because it talks to the repository, not directly to the database.

Independence from External Systems

Apps often rely on external systems like APIs, cloud services, or third-party libraries. But like a bad internet connection, you can’t always rely on them to be there. If you make your app overly dependent on these systems, you’re setting yourself up for trouble.

Why does external system independence matter?

  • External services can change, break, or be temporarily unavailable.
  • If your app is tightly coupled to external systems, a single outage could bring your entire app down.

How to achieve external system independence?

The solution is to use abstractions and dependency injection. In layman’s terms, instead of calling the external system directly, create an interface or a contract that your app can use, and inject the actual implementation later.

Example:

Kotlin
interface TaxServiceInterface {
    fun getCurrentTaxRate(): Double
}

class ExternalTaxService : TaxServiceInterface {
    override fun getCurrentTaxRate(): Double {
        // Call to external API for tax rate
        return api.fetchTaxRate()
    }
}

Now your app only knows about TaxServiceInterface. Whether the data comes from an API or from a local file doesn’t matter. You could swap them without the app noticing a thing!

Testability

Testing is like flossing your teeth. Everyone knows they should do it, but too many skip it because it seems like extra effort. But when your app crashes in production, you’ll wish you’d written those tests.

Testability is crucial to ensure that your app functions correctly, especially when different components (like databases and APIs) aren’t playing nice. Independent and modular architecture makes it easier to test components in isolation.

How to achieve testability?

  • Write small, independent functions that can be tested without requiring other parts of the app.
  • Use mocks and stubs for databases, APIs, and other external systems.
  • Write unit tests for business logic, integration tests for how components work together, and UI tests for checking the user interface.

Example:

Kotlin
class TaxCalculatorTest {

    @Test
    fun testCalculateTax() {
        val calculator = TaxCalculator()
        val result = calculator.calculateTax(100.0, 0.05)
        assertEquals(5.0, result, 0.0)  // expected value, actual value, delta
    }
}

In this test, you’re only testing the tax calculation logic. You don’t need to worry about the UI, database, or external systems, because they’re decoupled.

Note: 0.0 is the delta, which represents the tolerance level for comparing floating-point values, as floating-point arithmetic can introduce small precision errors. The delta parameter in assertEquals is used for comparing floating-point numbers (such as Double in Kotlin) to account for minor precision differences that may occur during calculations. This is a common practice in testing frameworks like JUnit.

Before wrapping it all up, let’s build a sample tax calculator app with these architectural goals in mind.

Building a Sample Tax Calculator App

Now that we’ve established the architectural goals, let’s create a simple tax calculator app in Kotlin. We’ll follow a modular approach, ensuring independence from frameworks, UI, databases, and external systems, while also maintaining testability.

Mobile App Architecture for Tax Calculation

We’ll build the app using the following layers:

  1. Domain Layer – Tax calculation logic.
  2. Data Layer – Data sources for tax rates, income brackets, etc.
  3. Presentation Layer – The ViewModel that communicates between the domain and UI.

Let’s dive into each layer,

Domain Layer: Tax Calculation Logic

The Domain Layer encapsulates the core business logic of the application, specifically the tax calculation logic. It operates independently of any frameworks, user interfaces, databases, or external systems, ensuring a clear separation of concerns.

Tax Calculation Use Case
Kotlin
// Domain Layer
interface CalculateTaxUseCase {
    fun execute(income: Double): TaxResult
}

class CalculateTaxUseCaseImpl(
    private val taxRepository: TaxRepository,
    private val taxRuleEngine: TaxRuleEngine // To apply tax rules
) : CalculateTaxUseCase {
    override fun execute(income: Double): TaxResult {
        val taxRates = taxRepository.getTaxRates() // Fetch tax rates
        return taxRuleEngine.calculateTax(income, taxRates)
    }
}
  • Independence from Frameworks: The implementation of CalculateTaxUseCaseImpl does not rely on any specific framework, allowing it to be easily swapped or modified without impacting the overall architecture.
  • Independence of User Interface (UI): This layer is agnostic to the UI, focusing solely on business logic and allowing the UI layer to interact with it without any coupling.

Data Layer: Fetching Tax Rates

The Data Layer is responsible for providing the necessary data (like tax rates) to the domain layer without any dependencies on how that data is sourced.

Kotlin
// Data Layer
interface TaxRepository {
    fun getTaxRates(): List<TaxRate>
}

// Implementation that fetches from a remote source
class RemoteTaxRepository(private val apiService: ApiService) : TaxRepository {
    override fun getTaxRates(): List<TaxRate> {
        return apiService.fetchTaxRates() // Fetch from API
    }
}

// Implementation that fetches from a local database
class LocalTaxRepository(private val taxDao: TaxDao) : TaxRepository {
    override fun getTaxRates(): List<TaxRate> {
        return taxDao.getAllTaxRates() // Fetch from local database
    }
}
  • Independence from Databases: The TaxRepository interface allows for different implementations (remote or local) without the domain layer needing to know the source of the data. This separation facilitates future changes, such as switching databases or APIs, without affecting business logic.

Tax Rule Engine: Applying Tax Rules

The Tax Rule Engine handles the application of tax rules based on the user’s income and tax rates, maintaining a clear focus on the calculation itself.

Kotlin
// Domain Layer - Tax Rule Engine
class TaxRuleEngine {

    fun calculateTax(income: Double, taxRates: List<TaxRate>): TaxResult {
        var totalTax = 0.0

        for (rate in taxRates) {
            if (income >= rate.bracketStart && income <= rate.bracketEnd) {
                totalTax += (income - rate.bracketStart) * rate.rate
            }
        }

        return TaxResult(income, totalTax)
    }
}

data class TaxRate(val bracketStart: Double, val bracketEnd: Double, val rate: Double)
data class TaxResult(val income: Double, val totalTax: Double)
  • Independence from External Systems: The logic in the TaxRuleEngine does not depend on external systems or how tax data is retrieved. It focuses purely on calculating taxes based on the given rates.
  • Independence of External Systems (Somewhat Confusing): A robust architecture should also be agnostic to the interfaces and contracts of external systems. This means that any external services, whether APIs, databases, or third-party libraries, should be integrated through adapters. This modular approach ensures that external systems can be swapped out without affecting the business logic.

For example, if an application initially integrates with a REST API, later switching to a GraphQL service should require minimal changes to the core application logic. Here’s how you can design a simple adapter for an external service in Kotlin:

Kotlin
// External Service Interface
interface UserService {
    fun fetchUser(userId: Int): User
}

// REST API Implementation
class RestUserService : UserService {
    override fun fetchUser(userId: Int): User {
        // Logic to fetch user from REST API
        return User(userId, "amol pawar") // Dummy data for illustration
    }
}

// GraphQL Implementation
class GraphQLUserService : UserService {
    override fun fetchUser(userId: Int): User {
        // Logic to fetch user from GraphQL API
        return User(userId, "akshay pawal") // Dummy data for illustration
    }
}

// Usage
fun getUser(userService: UserService, userId: Int): User {
    return userService.fetchUser(userId)
}

In this example, we can easily switch between different implementations of UserService without changing the business logic that consumes it.

In our tax calculation app case, we can apply this principle by allowing for flexible data source selection. Your application can seamlessly switch between different data providers without impacting the overall architecture.

Switching between data sources (local vs. remote):

Kotlin
// Switching between data sources (local vs remote)
val taxRepository: TaxRepository = if (useLocalData) {
    LocalTaxRepository(localDatabase.taxDao())
} else {
    RemoteTaxRepository(apiService)
}

Independence from Databases and External Systems: The decision on which data source to use is made at runtime, ensuring that the business logic remains unaffected regardless of the data source configuration.

Presentation Layer: ViewModel for Tax Calculation

The Presentation Layer interacts with the domain layer to provide results to the UI while remaining independent of the specific UI implementation.

Kotlin
// Presentation Layer
class TaxViewModel(private val calculateTaxUseCase: CalculateTaxUseCase) : ViewModel() {

    private val _taxResult = MutableLiveData<TaxResult>()
    val taxResult: LiveData<TaxResult> get() = _taxResult

    fun calculateTax(income: Double) {
        _taxResult.value = calculateTaxUseCase.execute(income)
    }
}
  • Independently Testable Components: The TaxViewModel can be easily tested in isolation by providing a mock implementation of CalculateTaxUseCase, allowing for focused unit tests without relying on actual data sources or UI components.

Testing the Architecture

The architecture promotes independently testable components by isolating each layer’s functionality. For example, you can test the CalculateTaxUseCase using a mock TaxRepository, ensuring that you can validate the tax calculation logic without relying on actual data fetching.

Kotlin
class CalculateTaxUseCaseTest {

    private val mockTaxRepository = mock(TaxRepository::class.java)
    private val taxRuleEngine = TaxRuleEngine()
    private val calculateTaxUseCase = CalculateTaxUseCaseImpl(mockTaxRepository, taxRuleEngine)

    @Test
    fun `should calculate correct tax for income`() {
        // Setup tax rates
        val taxRates = listOf(
            TaxRate(0.0, 10000.0, 0.1), // 10% for income from 0 to 10,000
            TaxRate(10000.0, 20000.0, 0.2) // 20% for income from 10,001 to 20,000
        )
        
        // Mock the repository to return the defined tax rates
        `when`(mockTaxRepository.getTaxRates()).thenReturn(taxRates)

        // Calculate tax for an income of 15,000
        val result = calculateTaxUseCase.execute(15000.0)

        // Assert the total tax is correctly calculated
        // For $15,000, tax should be:
        // 10% on the first $10,000 = $1,000
        // 20% on the next $5,000 = $1,000
        // Total = $1,000 + $1,000 = $2,000
        assertEquals(2000.0, result.totalTax, 0.0)
    }
}

This architecture not only adheres to the specified goals but also provides a clear structure for future enhancements and testing.

Conclusion

Mobile app architecture is like building a castle in the sky—you need to make sure your app’s components are well-structured, independent, and testable. By following the goals outlined here:

  1. Framework independence means you can switch frameworks without rewriting everything.
  2. UI independence ensures your business logic can work on any platform.
  3. Database independence lets you change how you store data without affecting how you process it.
  4. External system independence allows for flexibility in changing third-party services.
  5. Testability guarantees your app doesn’t break when you add new features.

Remember: A good app architecture is invisible when done right, but painfully obvious when done wrong. So, avoid the spaghetti code, keep your components decoupled, and, of course, floss regularly! 😄

Skill Up: Software & AI Updates!

Receive our latest insights and updates directly to your inbox

Related Posts

error: Content is protected !!