A short guide (that'll evolve with time) with one and only goal - to make us better developers.
Feedback: Welcome!
Proposals: Highly appreciated. 🚀
PRs for typos, better wording, better examples or minor edits are also very welcome!
The Ivy Architecture follows the Functional Reactive Programming (FRP) principles. A good example for them is The Elm Architecture.
Motivation
- Organize code (Scaleability)
- Reduce complexity (Separation of responsibility)
- Reuse code (Composability)
- Limit side-effects (Less bugs)
- Easier testing (Pure, Controlled Effects, UI)
- Easier refactoring (Strongly Typed)
graph TD;
android(Android System)
user(User)
view(UI)
event(Event)
viewmodel(ViewModel)
action(Action)
pure(Pure)
event -- Propagated --> viewmodel
viewmodel -- Triggers --> action
viewmodel -- "UI State (Flow)" --> view
action -- "Abstacts IO" --> pure
action -- "Composition" --> action
pure -- "Composition" --> pure
pure -- "Computes" --> action
action -- "Data" --> viewmodel
user -- Interracts --> view
view -- Produces --> event
android -- Produces --> event
Resources: (further learning)
- The Android Achitecture
- Clean Code
- Jetpack Compose Docs
- Functional Programming
- Category Theory for Programmers
- Functional Reactive Programming
- The Elm Architecture
- Maintable Software Architecture with Haskell
- The Dao of FP
- Don't walk away from complexity, run!
- Lambda Calculus
The Data Model in Ivy drives clear separation between domain
pure data required for business logic w/o added complexity, entity
database data, dto
(data transfer object) JSON representation for network requests and ui
data which we'll displayed.
Learn more at Android Developers Architecture: Entity.
Motivation
- Reduce complexity (JSON, DB specifics are isolated)
- Flexibility (allows editting on Data object on different levels w/o breaking existing code)
- Easier domain logic (unncessary fields are removed)
Data Model
graph TD;
data(Data)
entity(Entity)
dto(DTO)
ui_data(UI Data)
ui(UI)
network(Network)
db(Database)
viewmodel(ViewModel)
domain("Domain (Action, Pure)")
network -- Fetch --> dto -- Send --> network
dto --> data
db -- Retrieve --> entity -- Persist --> db
entity --> data
data --> entity
data --> dto
data -- Computation input --> domain
domain -- Computation output --> viewmodel
viewmodel -- Transform --> ui_data
ui_data -- "UI State (Flow)" --> ui
Example
DisplayTransaction
- UI specific fields
Transaction
- domain data
TransactionEntity
- has
isSynced
,isDeletedFlags
db specific fields (Room DB anontations)
- has
TransactionDTO
- exactly what the API expects/returns (JSON)
Motivation: This separation reduces complexity and provides flexibility for changes.
The Event
encapsulates outside world signals in an excepted format and abstracts user input and system events.
An Event
is generated from either user interaction with the UI or a system subscription (e.g. Screen start, Time, Random, Battery level).
Motivation
- Simplifies domain logic. (Abstracts Input)
- Makes ViewModel & Domain logic independent of Android & UI specifics. (Dependency Inversion)
graph TD;
user(User)
world(Outside World)
system(System Event)
ui(UI)
event(Event)
user -- Interracts --> ui
world -- Triggers --> system
ui -- Produces --> event
system -- Produces --> event
Note: There are infinite user inputs and outside world signals.
Triggers Action
for incoming Event
, transforms the result to UI State
and propagates it to the UI via Flow
.
Motivation
- Domain logic & UI independent of each other. (Dependency Inversion)
- Defines the behavior for each UI and connects it with the corresponding domain logic.
graph TD;
event(Event)
viewmodel(ViewModel)
action(Actions)
ui(UI)
event -- Incoming --> viewmodel
viewmodel -- "Action Input" --> action
action -- "Action Output" --> viewmodel
viewmodel -- "UI State (Flow)" --> ui
Actions accept Action Input
, handles threading
, abstract side-effects
(IO) and executes specific domain logic by compising pure
functions or other actions
.
Motivation
- Encapsulates domain logic.
- Make business operations (actions) re-usable. (Composability)
- Handles threading. (Reduces Complexity)
- Simplifies the ViewModel.
- Independent of UI State. (Dependency Inversion)
- Provide side-effects for the
pure
layer via Dependency Injection. (DAOs, Retrofit, etc)
Action Types
FPAction()
: declaritve FP style (preferable)Action()
: imperative OOP style
Action Graph:
graph TD;
input(Action Input)
output(Action Output)
pure(Pure Functions)
action(Action)
io(IO)
dao(Datbase)
network(Network)
side-effect(Side-Effect)
side-effect -- any --> io
dao -- DAOs --> io
network -- Retrofit --> io
io -- DI --> action
action -- Composition --> action
action -- Threading --> action
input --> action
action -- abstracted IO --> pure -- Result --> action
action -- Final Result --> output
Action Composition Examples
Calculate Balance
//Example 1: Calculates Ivy's balance
class CalcWalletBalanceAct @Inject constructor(
private val accountsAct: AccountsAct,
private val calcAccBalanceAct: CalcAccBalanceAct,
private val exchangeAct: ExchangeAct,
) : FPAction<CalcWalletBalanceAct.Input, BigDecimal>() {
override suspend fun Input.compose(): suspend () -> BigDecimal = recipe().fixUnit()
private suspend fun Input.recipe(): suspend (Unit) -> BigDecimal =
accountsAct thenFilter {
withExcluded || it.includeInBalance
} thenMap {
calcAccBalanceAct(
CalcAccBalanceAct.Input(
account = it,
range = range
)
)
} thenMap {
exchangeAct(
ExchangeAct.Input(
data = ExchangeData(
baseCurrency = baseCurrency,
fromCurrency = (it.account.currency ?: baseCurrency).toOption(),
toCurrency = balanceCurrency
),
amount = it.balance
)
)
} thenSum {
it.orNull() ?: BigDecimal.ZERO
}
data class Input(
val baseCurrency: String,
val balanceCurrency: String = baseCurrency,
val range: ClosedTimeRange = ClosedTimeRange.allTimeIvy(),
val withExcluded: Boolean = false
)
}
Overdue Transactions
//Example 2: Due transtions + due income/expense for a given filter
class DueTrnsInfoAct @Inject constructor(
private val dueTrnsAct: DueTrnsAct,
private val accountByIdAct: AccountByIdAct,
private val exchangeAct: ExchangeAct
) : FPAction<DueTrnsInfoAct.Input, DueTrnsInfoAct.Output>() {
override suspend fun Input.compose(): suspend () -> Output =
suspend {
range
} then dueTrnsAct then { trns ->
val dateNow = dateNowUTC()
trns.filter {
this.dueFilter(it, dateNow)
}
} then { dueTrns ->
//We have due transactions in different currencies
val exchangeArg = ExchangeTrnArgument(
baseCurrency = baseCurrency,
exchange = ::actInput then exchangeAct,
getAccount = accountByIdAct.lambda()
)
Output(
dueIncomeExpense = IncomeExpensePair(
income = sumTrns(
incomes(dueTrns),
::exchangeInBaseCurrency,
exchangeArg
),
expense = sumTrns(
expenses(dueTrns),
::exchangeInBaseCurrency,
exchangeArg
)
),
dueTrns = dueTrns
)
}
data class Input(
val range: ClosedTimeRange,
val baseCurrency: String,
val dueFilter: (Transaction, LocalDate) -> Boolean
)
data class Output(
val dueIncomeExpense: IncomeExpensePair,
val dueTrns: List<Transaction>
)
}
//Example 3: Overdue transactions + their income/expense
class OverdueAct @Inject constructor(
private val dueTrnsInfoAct: DueTrnsInfoAct
) : FPAction<OverdueAct.Input, OverdueAct.Output>() {
override suspend fun Input.compose(): suspend () -> Output = suspend {
DueTrnsInfoAct.Input(
range = ClosedTimeRange(
from = beginningOfIvyTime(),
to = toRange
),
baseCurrency = baseCurrency,
dueFilter = ::isOverdue
)
} then dueTrnsInfoAct then {
Output(
overdue = it.dueIncomeExpense,
overdueTrns = it.dueTrns
)
}
data class Input(
val toRange: LocalDateTime,
val baseCurrency: String
)
data class Output(
val overdue: IncomeExpensePair,
val overdueTrns: List<Transaction>
)
}
Actions are very similar to the "use-cases" from the standard "Clean Code" architecture.
Tip: You can compose actions and pure functions by using
"then"
,"thenMap"
,"thenFilter"
,"thenSum"
.
Tip: When creating an
Action
make it as atomic as possible. The goal of eachAction
is to do one thing efficiently and to be composable with other actions like LEGO.
The pure
layer must consist of only pure functions without side-effects. If the business logic requires, side-effects must be abstracted.
Motivation
- Avoid code duplication in
Action(s)
. (Composability) - Reduce complexity by abstracting domain logic from side-effects (DB, Network, etc) (Effect-Based system)
- Easier Unit Testing for the core domain logic.
- Enables Property-based Testing.
Function types
- Partial: not defined for all input values
@Partial(inCaseOf="b=0, produces ArithmeticException::class")
fun divide(a: Int, b: Int) = a / b
- Total: defined for all input values but for the same input isn't guarantee to always return the same output (has side-effects)
//It's defined in all cases but with each call returns a different output
@Total
fun timeNowUTC(): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
//Produdes logging side-effect which can be seen in Logcat
@Total
fun logMessage(
msg: String
) {
Log.d("DEBUG", msg) //SIDE-EFFECT!
}
- Pure: defined for all input values and for the same input always returns the same result (has NO side-effects)
@Pure
fun sum(a: Int, b: Int) = a + b
@Pure
fun logMessage(
msg: String,
@SideEffect
log: (String) -> Unit
) {
log("DEBUG: $msg")
}
Each @Pure
function must be total and its @SideEffect
(s) if any abstracted.
Rule: If a pure function is called with the same input and mocked side-effects it must always produce the same output.
Pure graph
graph TD;
input(Input)
pure(Pure)
side-effect(IO / Side-Effect)
lambda("@SideEffect Lambda")
output(Output)
side-effect -- Implements --> lambda
input -- Data --> pure
lambda -- Abstracted Effects --> pure
pure -- Calculates --> output
Code Example
//domain.action (NOT PURE)
class ExchangeAct @Inject constructor(
private val exchangeRateDao: ExchangeRateDao,
) : FPAction<ExchangeAct.Input, Option<BigDecimal>>() {
override suspend fun Input.compose(): suspend () -> Option<BigDecimal> = suspend {
exchange(
data = data,
amount = amount,
getExchangeRate = exchangeRateDao::findByBaseCurrencyAndCurrency then {
it?.toDomain()
}
)
}
data class Input(
val data: ExchangeData,
val amount: BigDecimal
)
}
//domain.pure (PURE)
@Pure
suspend fun exchange(
data: ExchangeData,
amount: BigDecimal,
@SideEffect
getExchangeRate: suspend (baseCurrency: String, toCurrency: String) -> ExchangeRate?,
): Option<BigDecimal> {
//PURE IMPLEMENTATION
//....
}
Tip: Make
pure
functions small, atomic and composable.
Renders the UI State
that the user sees, handles user input
and transforms it to events
which are propagated to the ViewModel
. Do NOT perform any business logic or computations.
Motivaiton
- UI independent of logic.
- Transform UI State into UI on the screen.
- Abstracts the ViewModel from UI specifics.
graph TD;
user(User)
uiState("UI State (Flow)")
ui("UI (@Composable)")
event(Event)
viewmodel(ViewModel)
user -- Interracts --> ui
ui -- Produces --> event
event -- "onEvent()" --> viewmodel
viewmodel -- "Action(s)" --> uiState
uiState -- "Flow#collectAsState()" --> ui
Exception: The UI layer may perform in-app navigation
navigation().navigate(..)
to reduce boiler-plate and complexity.
Responsible for the implementation of IO operations like persistnece, network requests, randomness, date & time, etc.
Motivation
- Encapsulate IO effects. (Reduce Complexity)
- Abstracts
Action(s)
from IO implementation. - Re-usable IO. (Composability)
Side-Effects
- Room DB, local persistence
- Shares Preferences, local persistence
- key-value pairs persistence
- will be migrated to DataStore
- Retrofit, Network Requests (REST)
- send requests
- parse response JSON with GSON
- transform network errors to
NetworkException
- Randomness
UUID
generation
- Date & Time
- current Date & Time (
timeNowUtc
,dateNowUtc
) - Date & Time formatting using user's
Locale
- current Date & Time (
Responsible for the interaction with the Android System like launching Intent
, sending notification
, receiving push messages
, biometrics
, etc.
Motivation
- Abstracts
Action(s)
andUI
from the Android System and its specifics. (Reduce Complexity) - Re-usable Android System efects. (Composability)
One of the reasons for the Ivy Architecture is to support painless, effective and efficient testing of the code base.
Motivation
- Verifies whether the code works as expected before reaching manual QA. (Stability)
- Easier refactoring. (Tests will protect us)
- Faster and more reliable QA. (Tests will ensure that the core functionality is working)
- Instant feedback for Pull Requests. (CI/CD)
Tests whether the code is working correctyly in the expected cases. (hard-coded cases)
Layers
Data Model
Pure
Tests correctness in not expected cases by random generating test cases in given a possible range of input. (auto-generated tests cases)
Layers
Data Model
Pure
Tests the integration and correctness with the Android SDK & System on specific Android API version. (end-to-end for logic)
Layers
Action
IO
Android System
Tests everything from the perspective of an user using the UI. Imagine it like an automated QA going through pre-defined scenarios. (end-to-end for everything)
Layers
UI
(Compose)
Version 1.3.0
Feedback and proposals are highly appreciated! Let's spark techincal discussion and make Ivy and the Android World better! 🚀