1

I am working on a hobby project of mine where I want to make an application to doing my personal finances. Just for learning. I am trying to adhere and implement best practices and patterns to learn and to evaluate how they work in real life.

So for this project I work in Kotlin and decide to follow the Clean Architecture. However I already am running into problems with defining my domain layer.

Let me first write out my simple definition of how my program should work.

  • I need to add, update and remove accounts
  • Accounts contain a name and a startingBalance
  • I need to add, update and remove transactions
  • Transactions contain a timestamp, fromAccount, toAccount, amount and description
  • Accounts and Transactions are part of a Ledger (the naming is maybe not correct, but I don't know the proper translation from Dutch to English).
  • I want to be able to open and close a Ledger. There is no interaction between the Ledgers.
  • I want to be able to get a mutation overview of an Account

So, now I come to the Domain definition of my application and there are two ways I thought I could do this:

1. Ledger as root entity

I define my data classes in Kotlin as follows:

data class Account(val id: UUID, val name: String, val startingBalance: Int)
data class Transaction(val id: UUID, val timestamp: Long, val fromAccount: Account, val toAccount: Account, val amount: Int, val description: String)

class Ledger {
    private val accounts : List<Account> = listOf();
    private val transactions : List<Transaction> = listOf();

    fun addAccount(name: String, startingBalance: Int): Account {
        // implementation
    }
    fun getAccountByName(name: String): Account?
    fun getAccountById(id: UIUD): Account?    
    fun updateAccount(account: Account): Account { ... }
    fun removeAccount(account: Account): UUID { ... }

    // same CRUD for transactions
    //....

    fun getMutationsForAccount(account: Account): List<Mutations> { ... } // some mutation class
}

However, this seems wrong to me. The Ledger is a Domain Entity and according to Clean Architecture they should be as stable as possible and the business rules (described above) should be in the use-cases and are now part of the entity. So this doesn't make sense I think.

2. Ledger as root entity, with separate use-cases

// same Account and Transaction data classes
class Ledger(val id: UUID, val accounts: MutableList<Account>, val transactions: MutableList<Transaction>)

class UseCases(val ledger: Ledger) {    
    fun createAccount(name: String, startingBalance: Int) { /* mutate ledger */ }
    fun getMutationsForAccount(account: Account): List<Mutations> { /* ... */ }
    // ... and the other use-cases //
}

Okay, this seems to be a lot better. However the Ledger class contains all the accounts and transactions. For my personal finance project this is okay I guess, but imagine a corporate entity with millions of transactions and thousands of accounts. This is all in memory now.... I know premature optimisation is the root of all evil, but this is a learning project, so I am just trying to extrapolate here :-)

I could separate out the Accounts and Transactions using a repository pattern so we get this.

3. Ledger as root but Accounts and Transactions using repository pattern

// same Account and Transaction defs

interface AccountRepository {
   fun createAccount(name: String, startingBalance: Int): Account
   fun updateAccount(account: Account): Account
   // rest of crud
}

interface TransactionRepository {
   // some crud above
   fun getTransactionsForAccount(account: Account): List<Transaction>
}

data class Ledger(val accountRepository: AccountRepository, val transactionRepository: TransactionRepository)

class UseCases(val ledger: Ledger) {
   // implement usecases here
}

So now we've inverted the dependency to the actual repositories, so they could be implemented by any backing data we want (in-memory, file, Oracle, etc).

But what is the purpose of Ledger now? It just references to the repositories.....

So this could be rewritten to:

4. No ledger anymore and just repositories

// same Account and Transaction data classes
// same repository interfaces

class UseCases(val accountRepository: AccountRepository, val transactionRepository: TransactionRepository) {
   // implement all the use-cases via the repositories
}

Conclusion

Some thoughts about it from me: I am working on a project at work where we have a very deeply nested hierarchy of entities. There are about different entities all referencing each other and a lot of cross-cutting concerns. However, everything is eventually under 1 root. So we persist only the root and serialize everything below it so the whole thing is loaded or unloaded in memory. However, even in the extreme case of some customers we wouldn't expect more than 5k-10k entities, so it easily loadable into memory. Even with several hundreds of customers working together we can scale this easily.

Would it be several hundreds of thousands or millions of entities then a different approach would be needed and it should be more lazily loaded and a more complicated pattern emerges where we probably would need multiple repositories for different entities etc.

So it boils down to what the business rules and expectations are and this guides the decision.

So in my hobby case: My intuition is that 2 and 4 are the only ones that make sense. I could make 2 immutable if I wanted to go for a pure functional immutable structure, but that is besides the point. But related to the business use (only me) solution 2 would work just fine.

Does my rationale make sense?

  • If not, what am I missing/getting wrong/misunderstanding?
  • If yes, what arguments can be made for choosing between 2 and 4?
  • My reaction is have you considered a database approach ( both to your hobby project and your work project ) ? – George Barwood Jun 26 at 7:38
  • 1
    Isn’t a database an implementation detail in Clean Architecture and as such doesn’t matter? – avanwieringen Jun 26 at 8:16
  • Bob Martin does make that claim, but I think he is talking nonsense there, or at least his claim is weak, you could say everything is a detail, including the "design". IMO, in many applications data and how it is organised and stored is what actually matters, the rest is "detail that doesn't matter". YMMV! – George Barwood Jun 26 at 11:58
  • I know it's somewhat orthogonal to the question, but this model does not properly describe a general double-entry system. For a start, there is no facility for one-to-many or many-to-many transactions. And there appears to be a way of introducing a new account with a non-zero starting sum, but no way to express the balancing entry, and no apparent way to remove the funds again via a transaction. – Steve Jun 27 at 0:23
  • Completely true. This is just a continuation of my Google Sheets financial administration which is just a list of transactions from and to accounts, and the accounts start with a balance. The balance at a certain date is just the summation of startingbalance and the applicabke mutations. In no way does this accurately model a proper financial system. – avanwieringen Jun 27 at 5:19
1

Design is always a question of choice. It's rarely a choice between good and bad, but often a choice between appropriate or less appropriate for your intents.

Let's have a short look on your (rich) set of ideas:

  1. The ledger as root entity of an aggregate is a valid because you view all other objects as being only relevant in the context of their ledger. You'd therefore always access them via the ledger.
    But according to Evans, an aggregate is "A cluster of associated objects that are treated as a unit for the purpose of data changes": do you want to manage the changes of all these object as a whole ? isn't this a too centralised view of the world?

  2. Same approach but with use-cases raises the question of teh same level of abstraction. Entities and their intrinsic operations are at the core of clean architecture. But here you move some entity operations of the domain to the application logic: is getAccountMutation something really application specific: wouldn't it be relevant whenever you work with acccounts and ledgers?

  3. The ledger is viewed as a composition of repositories. This seems a pragmatic approach: you still manage all your objects in a ledger, without having to always reference objects in relation with the ledger. But the nature of the ledger changes: it's no longer an aggregate or and entity. At the same time you multiply repositories for the same kind of objects. This may work very well for an in-memory app or if you the ledger defines the context of an application. But what if you're using an ORM layer that maps domain object to database tables: where would you see the ledger ? And if a ledger would have its own properties (name of the ledger, users who can have access, etc...) this approach would no longer work so well.

  4. Repositories, but no ledger anymore: wait, wasn't the ledger also important ?

A slight variant would therefore be to offer repositories for the different kind of entities, including for ledgers. In addition, you'd offer for each repository a possibility to get the entities corresponding to a ledger:

  • In this way you could manage entities as independently as possible.
  • You could navigate between a ledger to the relevant accounts.
  • Specific application logic could be build on the top of this domain-driven foundation
| improve this answer | |
  • Thanks for the elaborate answer! I’ll ponder on it a little bit more. – avanwieringen Jun 27 at 5:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.