Architecture
Before we start writing code, let's talk about how we should organize our application.
Over the years there have been many best practices for organizing code. These practices evolve as technologies change and we learn from maintaining previous approaches.
When we look at the application as a whole and consider all of its high-level parts, we're talking about the architecture of the application. The architecture describes the general division of responsibilities in your application, and which parts communicate with which other parts.
Breaking your application into chunks allows for
- Easier discussion of how the application works
- Easier developer tasking (especially in larger applications)
- Reuse of chunks across different types of applications (reusing the data layer, for example, across mobile apps, web services, and command-line applications)
- Easier independent testing of separate parts of your application
The architecture we use in this class is based on Modern Android Development, also known as "MAD". See https://developer.android.com/modern-android-development, which includes architecture, tools and libraries that Google recommends for Android development.
Architecture Overview
Applications are structured as Layers and Modules.
A "module" is a separately-compilable group of code and resources that can be consumed by other modules in your system. They're useful for isolating functionality, internal implementation details can be hidden while exposing a subset of function, its public API (Application Programming Interface). Separating code into modules also helps large applications isolate changes that many developers are making at the same time.
A "layer" is a grouping of one or more modules with certain responsibilities. Layers allow you to explicitly define communication between modules and further improve ease of maintenance.
Application Layers
graph TD
UI[User-Interface Layer] -->|depends on| Domain
Domain[Domain Layer] -->|depends on| Data[Data Layer]
At a high level, we talk about application layers, separating data from its manipulation and presentation.
Each layer isolates a responsibility in the application. At its simplest, each layer could be exactly one module. (While layers could be just conceptual grouping of code/resources that are all in a single module, using at least one module per layer gives us the ability to restrict what's exposed between layers.)
Layers could contain multiple modules, which I recommend as the application grows in size.
The Data Layer manages data acquisition and update. You can use it to persist data locally in a database, file or in memory. Or maybe your application connects to a server for its data; you can use the Data Layer to connect to a server to grab data and push changes.
By keeping your data access code inside the Data Layer, you can abstract and hide the how data is accessed from the rest of the application. If your data management code changes over time, the rest of the application may require fewer changes to adapt.
Operations in the Data Layer are sometimes called Primitive Operations, as they're the core, often simplest, operations you define on your data. Many primitive operations are simply property changes to objects and CRUD operations (Create/Read/Update/Delete) against your data store. You may also define more complex operations that are always needed for any user of the data.
The Data Layer doesn't depend upon other architectural layers, but may depend upon other modules/libraries such as database storage or network communication.
The Domain Layer is an optional layer that increases in value as the size of your application grows. Often you'll find that several parts of your application need to perform the same sequences of primitive operations. For example, a game might have several places that put an item in the player's inventory. It would be a great idea to create a function to contain the repeated code, but where do we put that function?
In a smaller application, you could add a function in the User-Interface Layer, and you've successfully factored out that common functionality.
As the application grows, the user interface might be broken down into multiple modules. Which UI module should host that functionality?
Or if you want to use the same "put item in inventory" for an Android application, desktop application, and web application, you no longer have a common user interface to host it.
A Domain Layer hosts this type of common functionality and gives you access from any user interface components, within the same application or across multiple applications.
These types of operations are generally called "macro operations". They build upon primitive operations by combining primitives to create more complex processing of the data.
In recent architectures, these types of operations are called Use Cases or Interactors.
The Domain Layer only depends upon the architectural Data Layer for access to its data. It can also depend upon other modules/libraries that provide frameworks, algorithms or support functions to work with the data.
Finally, we use our User-Interface Layer to present data to the user and interpret their actions. This layer can present the data using many different approaches: graphically, textually, as a service, or using assistive technologies for example.
Note
I've called out "as a service" as a type of User-Interface implementation. Think about what it means to be a "user". The "outsider" that's interacting with your application is its user. That could be a person, or it could be other applications. When we think of a service (such as a web service) as a "user interface", we're talking about a layer that prepares data for presentation (creating a JSON output, for example) and interprets user interaction (receives the service call, figures out what it means, and calls the appropriate functionality in the Domain (if present) or Data layers.)
The User-Interface Layer only depends on the Domain Layer (if present) or the Data Layer. This is somewhat controversial; some want to allow the User-Interface Layer to use the Domain Layer for complex operations, and skip past it directly access the Data Layer for simpler operations. The problem here is that it's possible to miss added value in the Domain Layer with this approach.
Suppose you have a Domain Layer that exposes macro operations to the User-Interface Layer, and also passes-through primitive operations from the Data Layer. If we allow the User-Interface Layer a choice of which layer it wants to work with, it may miss changes that the Domain Layer makes. For example, later on, as part of the simpler pass-throughs, if the Domain Layer adds some logging and verification (that the Data Layer doesn't have), it will be missed if the User-Interface Layer skipped around the Domain Layer to directly communicate with the Data Layer.
When using a Domain Layer, all User-Interface Layer modules must only depend on modules in the Domain Layer.
Data Layer
Let's dive a little deeper into the Data Layer.
The most basic part of the Data Layer is a Data Source. This is where you access your data. It could be a local or remote database, a web service, a file or another place to store your data. The data in this layer is usually persisted so it can be accessed across runs of the application.
Sometimes your application may use more than one data source. For example, if your application managed contacts, they might be stored in a server for access across devices. To reduce the user's network use, you may also have a local database that stores any contacts that have been previously accessed.
So how do you decide which data source to use?
Add a repository module. A repository can act as a switch between data sources. If the repository is asked for a contact, it checks to see if it's in the local-database data source. If so, it just returns it; no network communication needed! If not, it grabs the contact from the network, stores it in the local database, then returns it. (We're ignoring data that's been changed on the server in this example, but there are other technologies that make that easy to manage.)
flowchart LR
other[User Interface/Domain Layer] --> repo
ds1 --> db[(Database)]
ds2 --> file(((File)))
ds3 --> ws(((Web Service)))
subgraph Data Layer
direction LR
repo[Repository] --> ds1[Data Source 1]
repo[Repository] --> ds2[Data Source 2]
repo[Repository] --> ds3[Data Source 3]
end
(Arrows represent dependencies)
The repository can also perform another useful function - convert data that's used directly by a data source to data that can be returned to other layers. This conversion can
- limit which data is available to other layers (hiding data that you don't want to expose)
- add new data that's derived from a data source or pulled in from other sources
- change the accessibility of data, commonly making it immutable
- return objects that implement interfaces required by other layers
One of the most important things we'll learn about our User-Interface Layer is that immutable data makes everything more reliable and can help frameworks like Jetpack Compose optimize what needs to be refreshed. Immutable data stops its users for directly modifying it; they must use other functions to make changes, functions that will then know that changes are being made. This allows for easy enforcement of Unidirectional Data Flow, which we'll talk about later.
The repository copies the data from the actual data objects (often known as "entities") and creates Data Transfer Objects (DTOs) to carry that data. DTOs are often immutable, and can also restrict which data is visible or enhance the data with derived properties. DTOs may be a simple wrapper (an "Adapter" in design-pattern parlance), or an entirely-separate object that holds a copy of the data.
Using a repository helps abstract the way the data is accessed outside the Data Layer. Depending on your level of abstraction, this may result in better isolation between the layers. Changes to how you store data become less likely to force changes outside the Data Layer.
Note
"Less likely" depends on the amount of abstraction used when exposing data from the data layer, and this is a tradeoff. Higher abstraction requires more types (interfaces and/or classes) to be defined and maintained and reduces required external changes. Lower abstraction (such as directly passing data that's managed by your database) is less to develop and maintain, but requires more to change outside the layer when data-later internals change.
Think about how likely change is in your application. Is it likely you'll switch data sources? Is it likely the app will only be an Android application, or might you want to use parts of it in a desktop or web application? (Eventually, I think that Kotlin Multiplatform [KMP] will also allow easy reuse of much of your code on iOS as well.)
Domain Layer
The Domain Layer is optional, and we won't be using it in this class. It's overkill for small applications. As your applications grow, it becomes a much more useful.
flowchart LR
ui[User Interface Layer]
ui --> uc1
ui --> uc2
ui --> uc3
ui --> data
data --> repo
uc1 --> repo
uc2 --> repo
uc3 --> repo
ds1 --> db[(Database)]
ds2 --> file(((File)))
ds3 --> ws(((Web Service)))
subgraph Domain Layer
direction LR
data[Data]
uc1[Use Case 1]
uc2[Use Case 2]
uc3[Use Case 3]
end
subgraph Data Layer
direction LR
repo[Repository] --> ds1[Data Source 1]
repo[Repository] --> ds2[Data Source 2]
repo[Repository] --> ds3[Data Source 3]
end
(Arrows represent dependencies)
The gist of this layer is that is exposes data from the Data Layer along with Use Cases for common data modifications.
For more detail on the Domain Layer, please see https://developer.android.com/topic/architecture/domain-layer.
You may also be interested in learning about "Clean Architecture", which explains the use of a Domain Layer. Be careful though - adding a Domain Layer can increase the complexity of a smaller app and make its maintenance burdensome - you'll have to find that line, and much of depends on how many developers will be working on the same application. If only a few, it's best to keep it simple. If many developers on a larger application, the extra separation and explicit use cases can be a great help.
User-Interface Layer
The User Interface Layer is likely where most of your changes will happen, and will usually involve much more detailed code.
There are two major concepts in this layer:
- State - the data used to present the user interface
- User Interface - the means of allowing the user to consume and interact with the data
State
State includes data from other layers as well as data that's only used for controlling the user interface. If you have an application that's displaying contact information, the contact data comes from the other layers and is the "what" that you want to display. But you'll also need other information such as
- What screen is user seeing?
- Which position in a list is at the top?
- What field currently has focus?
Some data only makes sense for a specific user interface. It's easiest to determine which data this is if you ask yourself "would I need this data for a graphical user interface, a command-line interface, and a web service?" If so, the data likely belongs in the Data Layer. Otherwise, the data only exists in the User-Interface Layer.
Our goals in the User-Interface Layer are to
- Observe state changes
- Prepare our state for presentation to the user
- Interpret user interaction
- Update the state as needed (which will trigger our observers)
We'll be using Jetpack Compose as our user interface in this class.Compose has great state-management support, often making the state observation invisible, but sometimes it can be tricky to set up. We'll work through several types of state use in this class, but be aware that best practices for state are still being developed and may change from what I present. Keep an eye on https://developer.android.com/ site and the https://android-developers.googleblog.com/ blog for emerging details.
As part of the User-Interface Layer responsibilities, we need to prepare and update state. This function is usually managed by a View Model.
View Models
The concept of a View Model is simple - it's a place to manage state preparation and updates.
flowchart LR
subgraph User Interface Layer
ui[User Interface] --> vm[View Model]
end
vm --> repo
ds1 --> db[(Database)]
ds2 --> file(((File)))
ds3 --> ws(((Web Service)))
subgraph Data Layer
direction LR
repo[Repository] --> ds1[Data Source 1]
repo[Repository] --> ds2[Data Source 2]
repo[Repository] --> ds3[Data Source 3]
end
flowchart LR
subgraph User Interface Layer
ui[User Interface] --> vm[View Model]
end
vm --> uc1
vm --> uc2
vm --> uc3
vm --> data
uc1 --> repo
uc2 --> repo
uc3 --> repo
data --> repo
ds1 --> db[(Database)]
ds2 --> file(((File)))
ds3 --> ws(((Web Service)))
subgraph Domain Layer
direction LR
data[Data]
uc1[Use Case 1]
uc2[Use Case 2]
uc3[Use Case 3]
end
subgraph Data Layer
direction LR
repo[Repository] --> ds1[Data Source 1]
repo[Repository] --> ds2[Data Source 2]
repo[Repository] --> ds3[Data Source 3]
end
(Arrows represent dependencies)
Note
You may have multiple view models. Some developers prefer one view model per screen, others prefer by types of data, and others prefer a single view model. The decision often depends on the size of the application as well.
A view model creates state to be used by the user interface. In some cases, it may just pass the state along. In other cases, it may modify or combine state from underlying layers.
View model functions allow the user interface to tell it what the user wants to do, such as adding/deleting a contact or switching to a different screen. These functions will interact with the Domain Layer (if present) or Data Layer to update the data.
The view model needs to expose the state in a way that the user interface can observe it. Depending on the UI framework being used, the state might be exposed using the Observer pattern, using Android LiveData
, Jetpack Compose State
objects, Kotlin Flow
s or other approaches.
For our work, we'll be using Kotlin Flow
s and Compose State
s. We'll talk about what those are in later modules.
Composable Functions
For our user interfaces, we'll use Jetpack Compose to declare our user interface.
In a nutshell, you'll create Composable functions in Kotlin. These functions will emit descriptions of parts of the user interface to a tree. The Compose UI framework will look at this tree and create a user interface.
The cool thing is that Compose can watch the parameters passed to a Composable function. If the values change from one call to the next, it will recompose, emitting a replacement for the part of the tree that it previously emitted. The Compose UI will then detect changes and refresh only the parts of the UI that need to be refreshed.
Data Objects
Different types of data flow between different parts of the application. Your application may use
- Raw data - data obtained in its raw form from a database, file, service, etcetera. The data source formats it into an object to be passed back to the repository
- Entities - data obtained from data sources
- Data Transfer Objects (DTOs) - data abstracted/restricted before being passed out of the Data Layer
- State - data prepared for use in the user interface
A typical flow of data might look like
flowchart RL
subgraph User Interface Layer
vm[View Model] -->|State| ui[User Interface]
end
repo -->|DTO| vm
db[(Database)] -->|raw| ds
subgraph Data Layer
ds[Data Source] -->|Entity| repo[Repository]
end
flowchart RL
subgraph User Interface Layer
vm[View Model] -->|State| ui[User Interface]
end
Data
data -->|DTO| vm
repo -->|DTO| data
db[(Database)] -->|raw| ds
subgraph Domain Layer
direction TB
data[Data]
uc[Use Case]
end
subgraph Data Layer
ds[Data Source] -->|Entity| repo[Repository]
end
Events
So how do we make changes? That's where events come in.
The user interface receives user interactions, such as key and button presses, and interprets their meaning. It then calls Event functions to trigger state changes.
When using Jetpack Compose, these event functions are typically Kotlin lambdas passed into the functions we use to create the user interface. Most of these lambdas will call functions in the view model; others may update some local state in the user interface itself.
flowchart LR
subgraph User Interface Layer
ui[User Interface] -->|"event()"| vm[View Model]
end
vm -->|"update()"| repo
ds -->|query| db[(Database)]
subgraph Data Layer
repo[Repository] -->|"update()"| ds[Data Source]
end
flowchart LR
subgraph User Interface Layer
ui[User Interface] -->|"event()"| vm[View Model]
end
vm -->|"update()"| uc
uc -->|"update()"| repo
ds -->|query| db[(Database)]
subgraph Domain Layer
direction TB
data[Data]
uc[Use Case]
end
subgraph Data Layer
repo[Repository] -->|"update()"| ds[Data Source]
end
We'll see how this works when we start coding our user interface.
Concurrency
User interfaces are typically updated via a single thread. That thread is responsible for drawing any needed changes and responding to user interactions. The UI should respond immediately to interactions such as pressing a button, scrolling the screen, or drawing pictures with their finger.
Many user actions result in performing data updates, which can be expensive. If we perform these actions on the same thread that detected the user interaction, all screen updates are blocked until that update has completed. At best, this can result in "jank", an interface that doesn't immediately respond and jumps between drawn frames. At worst, an update might take long enough that the user interface freezes.
The easiest fix for this is to run data updates on a different thread so the user interface can keep responding immediately. We'll do this using Kotlin Coroutines, but it could also be done using threads and executors.
While the UI is now responsive, we can run into data synchronization, race and deadlock issues. Fortunately there are several patterns we can follow to more reliably work with concurrent processing.
One of the most effective ways to help is to use a Unidirectional Data Flow, or UDF.
Unidirectional Data Flow
By passing data into functions, and receiving event calls out, we remove the possibility of data changing while it's being read.
Jetpack Compose's Composable
functions take parameters for data and events. For example, we might define a "submit button" function:
@Composable
fun Submit(
buttonText: String,
onButtonPress: () -> Unit,
) {
...
}
Note
The convention for Composable functions that emit ui nodes to the tree is that they are named using UpperCamelCase()
. This feels a bit weird, but the idea is that we're treating these functions as declarations of what the ui looks like, not imperative code to build a UI. Declarations feel more like class or interface definitions, hence the case.
The buttonText
is data coming in; the Submit()
function will emit a button to the tree that displays that text. Submit()
will also attach a "click listener" to the button. When the button is pressed, it will call onButtonPress()
to tell the caller of Submit()
that the button was pressed.
Data comes in; events go out. That's Unidirectional Data Flow.
Somewhere up at the top of the call chain, a lambda is passed in to be used as that onButtonPress
parameter, and it contains the code to perform the update.
That lambda should immediately switch to a different thread to perform its work. We'll do this by launching a coroutine to perform the work. At the end of the coroutine, new data will be set for the state and passed in.
For this example, if the new state contains the buttonText
that's passed into SubmitButton()
, the UI will be updated.
We'll dig into this when we start talking about Jetpack Compose. For now, all you need to know is we'll be pushing data and event functions into the Composable functions, and call the event functions to indicate that something has changed.