1

Problem

In my application a user can lock a set of related aggregate roots to work exclusively on them and to avoid the usage of an invalid set of objects later in the process (by other employees). This problem has nothing to do with the technical concept "optimistic locking", it is a kind of "domain locking".

The model looks simplified something like this:

class Container {
    val id: ContainerId
    val isLocked: Boolean = false
    val lockedBy: UserId? = null
}

class PartA {
    val containerId: ContainerId
}

class PartB {
    val containerId: ContainerId
}

All of these objects are aggregate roots and there is no invariant of the Container that could be enforced with information from PartA or PartB. So I think, it was the right choice to model these objects as aggregate roots.

However, neither PartA nor PartB must be edited if the Container is locked. At the moment the lock is enforced at the application service level:

class PartAService(
    private val containerRepository: ContainerRepository,
    private val partARepository: PartARepository
) {
    fun change(data: Something) {
        val container = containerRepository.findById(data.containerId)
        if (container.isLocked) {
            throw ContainerLockedException()
        }
        val partA = partARepository.findById(data.partAId) 
        // modify partA ...
    }
}

This design is simple and straightforward, but it is also error prone, because you have to remember to perform this check in each related service method.

Possible Solution

My idea to improve the design is to lock each aggregate root instance. To do so a ContainerLocked event would be published that would be handled by handler for PartA and PartB. Both classes would than hold the lock themselves and would check it in methods that modify its state:

class PartA {
    val containerId: ContainerId
    val isLocked: Boolean = false
    val lockedBy: UserId? = null

    fun modify(x: Int) {
        if (isLocked) {
            throw PartALockedException()
        }
        // modify state ...
    }
}

class PartB {
    val containerId: ContainerId
    val isLocked: Boolean = false
    val lockedBy: UserId? = null

    fun modify(x: Int) {
        if (isLocked) {
            throw PartBLockedException()
        }
        // modify state ...
    }
}

As you can see there is still the need to check the lock, but it is local now and hopefully much easier to overview.

Technically the locking and unlocking could be implemented with a single update at the database, so that there shouldn't be a big performance impact (one lock/unlock would affect about 3000 rows).

Addendum: This solution would only work for existing objects and is thus only a partial solution. To avoid two approaches, another approach must be found.

Question

What solution would you prefer and why? Is there any better idea?

2
  • For how long can a user hold a lock on a set of aggregate roots? Is the set to lock pre-determined or does the user get to choose which roots to lock? What should happen if a user wants to edit two roots, one of which is locked? Can you design the system such that a user must explicitly lock the affected roots before any modification is accepted (i.e. only the user holding the lock is allowed to modify)? Commented Feb 19, 2021 at 9:58
  • The user would typically hold the lock for up to a few hours. The use can only lock Container and there are no relationships between Container instances. So the number of locked containers has no relevance in this context.
    – deamon
    Commented Feb 19, 2021 at 12:04

2 Answers 2

1

One possible solution is to have all modifications go through their container. This way you can make sure they are properly locked/unlocked/checked and do not have to rely on each user site to get it right themselves.

Without knowing the exact use-case, I would do that this way:

class Container(...) {
   fun lock() { ... }

   fun unlock() { ... }

   fun modifyA(logic: (PartA) -> Unit) {
      // make sure not locked / etc.
      logic(partA)
   }

   fun modifyB(logic: (PartB) -> Unit) {
      // make sure not locked / etc.
      logic(partB)
   }
}

The key point is that user code should not get direct access to PartA, and PartB, since that would expose them to things they may get wrong (i.e. the locking).

You may have to pass the "user" or something else additionally to these methods, depending on how you implement it.

2
  • I like the idea. Maybe this could be generalized to something like executeInContextOfContainer. But since the passed function would call public methods of PartA or PartB it would still be possible to skip the call to the Container method first.
    – deamon
    Commented Feb 19, 2021 at 13:55
  • The final solution is inspired by your suggestion. The main difference is that the Container doesn't need to know something about the other aggregates. However, this mechanism could still by bypassed like in your example. I don't see a solution that could prevent it properly.
    – deamon
    Commented Feb 23, 2021 at 14:15
0

Locking individual objects of other aggregate roots would solve the problem only partially, because it wouldn't cover the creation of new objects. So the lock must be checked at the service level.

One approach could be a "mega service" that would contain methods for all aggregate roots affected by the lock. This service would then check the lock in every method. But that could easily become a very big class.

Probably the better apprach is a method in the Container class to execute a function in its context, if the Container is not locked.

class Container {

  // ...

  fun <Result> executeIfUnlocked(action: () -> Result): Result {
    if (isLocked) {
      throw IllegalStateExeption("Container is locked.")
    }
    return action()
  }

}

The Container has no dependency to any other aggregate root with this design.

And then in a service:

class PartAService {

  fun create(...): Long {
    val container = containerRepo.findById(...)
    return container.executeIfUnlocked {
      val partA = PartA(...)
      partARepo.add(partA)
    }
  }
}

This design doesn't prevent the accidental bypassing of the lock, but there is not much you can do about it.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

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