Leave transaction control to the client

Fernando Martin
Nerd For Tech
Published in
4 min readFeb 10, 2021

Every domain eventually runs into this. A business operation needs to maintain invariants between two or more entities on a given model. How can accomplish it without messing the model? Which component should be responsible for ensuring that?

For example, let’s say your domain has members and stories written by them and each profile should display the number of stories published by the member. The team decides to add a “storiesWrittenCount” to the Member Entity to avoid an extra query to the Story Collection.

A naive first attempt to satisfy that scenario would be:

WriteStoryUseCase(story, member) {
storyRepo.create(story)
member.storiesWrittenCount++
memberRepo.update(member)
}

With this approach there is a risk of a failure on the members update after a successful story creation, breaking the story count invariant.

In the past, I have addressed the challenge of bringing transactional support between two or more entities in three different ways

Option 1: Ignore it

The risk is low and it’s late in the sprint. You don’t want to have another discussion in the retro about story pointing.

Option 2: Leak the transactional implementation to the domain

WriteStoryUseCase(story, member) {
MongoClient client = ...
ClientSession clientSession = client.startSession()
...create story and update member... clientSession.withTransaction(...)
}

At this point, your domain has a dependency on your infra layer (Mongo in this case) and is no longer “clean.”

Option 3: Create a “StoryMemberRepo”

WriteStoryUseCase(story, member) {
storyMemberRepo.writeStoryAndUpdateMember(story, member)
}

Here we avoid the DB dependency, however, these repository mixes concepts and your codebase will likely end up with a MemberRepo, a StoryRepo, a StoryMemberRepo, a StoryMemberNotificationRepo, and so on. Now it’s no longer clear where to find functionality, leading to developer confusion and likely code duplication.

An alternative

While I was reading Domain-Driven Design, a particular sentence caught my attention and got me thinking: “Leave transaction control to the client.”

How about abstracting the concept of Transaction in the Domain and let the client initiate and commit units of works?

To the code!

Kotlin and Mongo are the technologies of choice, you can check the full example in Github.

I defined the Transaction:

interface Transaction {
fun start()
fun commit()
}

And its Mongo representation:

class TransactionMongo private constructor(...) : Transaction {
private lateinit var session: ClientSession

override fun start() {
session = client.startSession()
session.startTransaction()
}

override fun commit() {
try {
session.commitTransaction()
} catch (e: Exception) {
session.abortTransaction()
throw TransactionAbortedException(e);
} finally {
session.close()
}
}
}

Each functionality that saves an Entity in the Repo receives an optional Transaction as input and returns a Transaction:

interface MemberRepo {
fun save(member: Member, tr: Transaction? = null): Transaction
}

With its implementation:

class UserRepoMongo(private val mongoClient: MongoClient) : UserRepo {
override fun save(user: User, tr: Transaction?): Transaction {
val transactionUsed = mongoClient.getTransaction(tr)
collection.save(transactionUsed.session(), user)
return transactionUsed
}
}

I added an extension function to MongoClient for simplicity:

fun MongoClient.getTransaction(tr: Transaction?): TransactionMongo {
return if (transaction == null) {
val newTransaction = TransactionMongo.create(this)
newTransaction.start()
return newTransaction
} else {
transaction as TransactionMongo
}
}

Finally, my use case looks like this:

WriteStoryUseCase(story: Story, member: Member) {
val tr = storyRepo.save(story);
val memberUpdated = member.incrementStoriesWrittenCount()
val trAfterSave = memberRepo.save(memberUpdated, tr)
trAfterSave.commit()
}

With this approach, there is no dependency on the DB into the domain. Also, there are no mixing concepts of the two entities.

The complexity added is that now the model needs to deal with a new concept of Transaction. This shifts the responsibility of committing units of work to the model, which design-wise is a sound idea.

Finally, if the domain needs the creation of stories from many use cases, you can work on a service that does that and can be shared across your model.

Model

Let’s take a look at the model used to tackle this example (Github).

The App layer is the one that has global access to control the flow and perform the dependency injection.

The Repos and Transaction layers contain the details needed to implement the different entities and repositories defined in the inner layers. In this particular case, Mongo.

Use Cases is where the actual business logic resides, the “domain layer.”

Here is the UML Diagram with the main components of the example:

You can see how the Use Case only has access to the entities and interface repos; the details are injected in the App layer.

And that’s all for now. Thank you for reading! This is my first ever medium story — hope you enjoyed it.

You can follow me on Medium, Twitter, and Linkedin, where I will be sharing my thoughts and experiences in Software Development, Engineering Management, and more.

--

--

Fernando Martin
Nerd For Tech

15 years of experience in Software Development. Passionate about delivering software at scale and increasing the effectiveness of Engineering teams.