Skip to main content

Android Actual Clean Architecture (AACA)

by Adrian Tache, 01.05.2024

Introduction

Why use architecture?

TL;DR

Architecture helps avoid complexity, therefore enables the project to grow and be maintained easier, improves collaboration within the team, and results in components that won’t just be completely rewritten due to future changes in features, frameworks or technologies.

If you’ve ever worked on a long term project, or a legacy project, then you surely know that, as the code base grows, complexity grows, and things get messy with hot-fixes and “quick win” projects which just sabotage the project for minor user or business improvements. While this is a constant for any software project, adequate planning, assisted by a clear, well-structured architecture, can help reduce that complexity and make it easier to manage in general.

Of course, architecture alone is never enough, as you need a team of people dedicated to understanding it and making it work properly or else you’ll never reap the benefits. On the flip side, such a formal architecture might not even be necessary if you work with a team of exceptionally skilled and communicative developers, but even then, there will be newcomers to the team who will be helped by it.

Practical example

I once worked on a project that was a very quick rollout, but in a very disorganized way, meaning that a lot of features changed significantly from first implementation to final result. Because of this, a lot of the initial assumptions about the structure and functionality of the code ended up being outdated, but the fact that changes came as a small trickle of minor additions or alterations meant that this change was insidious and unnoticed, until simple things like adding a piece of UI took an unreasonable amount of time and led to strange bugs.

Furthermore, within the same project, a lot of UI was implemented multiple times, which led to a partial or full rewrite with every change, since so much of the app logic was coupled to the UI. Essentially, when a button changed to a slider, for example, the logic behind it would need to change since the abstraction was so tightly linked to the implementation of the UI. With this new architecture, details like this simply don’t matter, as the logic is completely separate from whatever UI the user sees. Of course, nothing is a magic bullet, so there will still be changes in your domain layer when UI changes drastically, but the better and cleaner the domain is built, the simpler it is to wrap a UI on top of it.

And finally, in this same project, a lot of the developers were new to the team, with no senior developer to take the lead, meaning that we had multiple implementations and structures within the same project, leading to even more chaos and interoperability issues. If we had had this architecture, at the very least everyone would have had to use the same broad structure of code, with their own flourishes only being applied to small details.

Why use this architecture?

TL;DR

This architecture is built on top of Clean Architecture to cleanly separate business logic from other components, thus improving scalability, testability, resilience of business logic, as well as collaboration and onboarding of new members.

I’ve been working with this architecture for a couple of years now, and I’ve noticed that it provides more and more value the more I work with it. It’s very refreshing to have a UI layer that doesn’t concern itself with logic, and to have all the logic encapsulated in one layer, responsible for its own functioning, far from the temptation of spaghetti or “creativity” (by which I mean the temptation for people to constantly reinvent the wheel just so that they feel ownership over a piece of code).

It is built on top of Clean Architecture principles, and I highly invite you to read the book and draw your own conclusions (and maybe suggest improvements as well). As such, it offers many of its benefits, such as:

  • Separation of Concerns - each layer has its own, clear purpose
  • Enhanced Maintainability - maintenance is easier thanks to modularity, and clear boundaries between components reduce the surface area of a fix
  • Improved Scalability - as modules or layers can be scaled up and down, or even added and removed as needed, the app grows more easily
  • Framework Independence - as business logic is protected at the core of the app, changing frameworks or even programming languages has a much smaller impact
  • Testability - again, since business logic is separated from other components such as UI, and fundamentally Android-free, everything can easily and simply be unit-tested based on inputs and outputs, without requiring a simulation of multiple systems at once (depending on your definition of unit tests)
  • Everything is a Detail - UI, data storage, API access, platform interactions are all separated from the core logic of the app, and thus can be decided later in time or swapped out/tested as needed, without requiring a rewrite of that core logic
  • Focus on Business Logic - Entities hold the same business logic that real life operations follow, keeping things easier to visualize and process, and more importantly easier to ensure robustness in these processes, free from application or UI details

Furthermore, it has some benefits of its own, especially as applied to Android:

  • Consistency - each component plays its own role and it’s easy to just add all components to every new component and reap a lot of the benefits with very little effort, even by someone who doesn’t completely understand the architecture
  • Unidirectional Data Flow - this implementation of Clean Architecture ensures that the UI can never access or instruct business or application logic, since it cannot access any of the functionality unless allowed to, using States
  • Automation - scripts can be written for most boilerplate such as mappers or mapper tests, so that a lot of the tedious parts of this architecture can be simplified
  • Collaboration - people can work on different layers of the same component by simply agreeing on the interfaces and objects that communicate between those layers, and then simply treat the other person’s part as a black box, which will actually improve the overall quality by avoiding coupling
  • Architecture Unit Testing - Using a testing framework such as ArchUnit or Konsist, the architecture can be enforced automatically, avoiding spaghetti code and simplifying code reviews
  • Predictability - while this architecture adds some complexity of its own, it is structured, meaning that the answer to “where is X” is always easy to answer, by first going to the layer that contains that component (UI, business logic, data layer, etc.) and then following the abstractions down until you reach the tangible implementation you search for (or ignore that detail altogether and focus on the interactions between black boxes, since their behaviour is predictable)

To put it shortly, it helps you organise your app in such a way that logic has its own place, where it is free from any Android dependencies or most implementation details, thus easy to unit test, easy to experiment with new components or frameworks outside this logic, and easy to work on together with your colleagues.

Why not just use MVVM + repository?

TL;DR

MVVM + Repository does not offer the clear separation of concerns that Clean Architecture does, and therefore gives developers too much freedom to write spaghetti code or to tightly couple business logic to UI elements.

Most Android projects use the MVVM architecture, plus an optional repository to coordinate data sources. While this architecture is fine for simple projects, when complexity and teams start to grow, its weaknesses are exposed, primarily from the fact that there are no strong rules regarding important things such as where to put the business logic. Far too often, I’ve seen people using this architecture putting logic either directly inside UI components, or inside ViewModels, where it is very tightly coupled to those UI components, or in the repository where it becomes inevitably coupled to the data sources, or, of course, split between these three components in very unpredictable and problematic ways. The main issue with this is that it makes reading code, and therefore maintaining code, an absolute nightmare, with side effects potentially hiding in every file, and the developer needing to look inside multiple files simultaneously to get the big picture as to how they interact.

Worse still, I’ve seen a lot of people add a “domain” layer to this architecture, which usually represents a simple pass-through layer that, at most, does some sorting and filtering, and which adds yet another place to hide logic in. And, of course, many people call this “Clean Architecture”, without noticing the irony that it offers none of the benefits of Clean Architecture, since just adding an ambiguous layer doesn’t magically bring structure to your code.

MVVM still has a place in this architecture, as does the Repository, but as implementation details and no as core components of the architecture, as will be detailed in further chapters.

Why is it DevOps?

TL;DR

DevOps is all about delivering quickly to production, in a safe manner, and having a predictable, testable architecture enables this more than some automated tools running simplified tests which are written only to comply with code coverage requirements.

While I’m no expert in DevOps, it feels to me like the whole concept is having an evolution similar to Agile, in that a lot of the focus is on tooling to enable CI/CD, and moving away from the principles and philosophies that I feel would serve it better. That is no to say that tooling isn’t important, but that at its core, the point of DevOps is to streamline both the software development and deployment processes, and, to put it simply, to allow us to deliver features quickly into production, while being sure that we are doing so in a safe manner.

If we accept this latter definition of DevOps, then it is quite obvious to me that having good structure in our code, as well as good testability (both in how easy it is to write the tests but also how accurately they cover the functionality and how well they guard against bugs and regressions) is a core requirement for DevOps.

Clean architecture enables you to deliver quickly into production by having an easy to follow template for each feature, which allows you to know your progress and easily judge how close to release a feature is. It also enables this through parallelization, where you can have multiple developers on separate layers of the same feature, since they are all decoupled. Furthermore, it brings speed from how easy it is to make changes or swap different implementations, even running them in parallel until testing is complete and the feature has been tested to not create issues for real users. Finally, it gives speed to development through maintainability and ease of debugging, since the most critical logic is all isolated and very easy to test and debug.

It also enables you to deliver safely, by making it easy to write unit tests to achieve full coverage of the domain layer, in a way that truly matters, by testing each component individually, but also focusing on inputs and outputs, so that refactoring doesn’t mean that the tests are no longer valid. It also brings safety since everything is very clearly separated, and a random UI change won’t have the ability to fundamentally attack the core logic of the app. Similarly, whenever there is an issue with this core logic, it should be very easy to identify it and operate only on that, with a reduced chance of side effects and regressions due to the clean separation between components and robust test coverage.

Rules

While this architecture is permissive, meaning that it is a guidance more than it is a hard structure that needs to be followed strictly, there are some important aspects that should be observed in order to get the full benefit of it.

1. Start from the business rules and move outwards

It is very important that development of a feature using this architecture starts in the domain layer, and as disconnected from the UI and data storage / APIs as possible. It is a good idea to firstly define the business logic and all its rules (and ideally unit test them as well), before moving on to the application logic (use cases), and then the other layer interactions (UI communication objects, data layer interface and communication objects). It is also very important at this point that the application should be “complete”, in that no further development should have an impact on this layer, only the other way around. Every decision should start from the business logic outwards, and never the other way around.

2. Every component matters

While this architecture may seem verbose (it has on occasion even to myself), each component has a purpose, and there is a loss associated with removing it. You can certainly remove massive parts of it once you feel confident that you can add them back later if you need them, but it is critical that you understand why they are important. For example, mappers between data objects in the domain layer and data objects in the data layer may seem like a waste of time, but in some cases this greatly simplifies the mapping process and its testing (for example going from ISO date strings in the data layer to timestamps in the domain layer), and prevents you from accidentally coupling this mapping process to the wrong layer.

3. Don’t take shortcuts in the architecture

Sometimes, things might seem so much simpler to implement if you just skip a layer, or keep some logic locally, for example let the ViewModel communicate directly with the repository to fetch some simple information, like a boolean. While it is tempting to do so, don’t do this unless you completely understand how the structure would look without this shortcut, and it would be trivial for you to implement it if the feature becomes more complex. Even then, I still recommend building the full structure, because if it is so trivial to implement, it will be quick to write the extra objects and mappers anyway, and then you’re fully covered if you do need to make changes. The main reason for this is that, even if you do skip layers, you need to keep the same way of thinking, starting at the business layer and effectively decoupling the UI and data layers from the actual logic, otherwise it’s very likely that any change in this component will lead to a complete rewrite.

4. Read everything

I highly recommend going through this entire document at least once to understand the reasoning behind a lot of the components and how they interact with each other, otherwise you might be tempted to consider them useless, and feel like there's no downside to skipping them. If something isn't clear, try to implement it anyway, and then see how the rest of the code interacts with it.

5. Single source of truth

State should always be kept in only one place: the entity instances inside the use case. This helps avoid a lot of issues, like views having their own logic and state that gets out of sync with the main program, like users being able to perform operations from states that are no longer valid, potentially leading to crashes, and of course it helps a lot with preventing the logic of the app from being spread around through all the layers.

6. Immutability

All or most objects in this architecture are intentionally kept immutable. This ensures atomic updates that don't compete with each other, and it makes sure that an operation on an entity doesn't leave data behind. Finally, since the states are passed through Kotlin StateFlows, it is very important that objects are not identical to each other.