DDD Studies: Tactical Design between Fishermen and Developers

Continuation of Domain-Driven Design studies, now exploring tactical patterns, through a fishing equipment catalog, implementing Entities, Value Objects, Aggregates and Repositories

Portuguese version here: https://tech-pills.github.io/2025-08-31-estudos-em-ddd-design-tatico-entre-pescadores-e-desenvolvedores/

Hey everyone! Hope you’re doing well.

Another article to continue our DDD studies, this time focused on tactical design and the patterns that compose it.

Making it clear that this is a continuation of our first article DDD Studies: Between Fishermen and Developers, so reading it is mandatory to have a complete understanding.

What is it, what is it

  • they seem to have an identity;
  • it’s not the attributes that necessarily matter;
  • they have continuity within the system;
  • they can be differentiated;

Exactly, these are the objects we call Entities!

Normally, when we’re going to assign an identity to something or someone, it’s the sum of one or more of their attributes (or behaviors) that makes them unique.
And it’s extremely important that it’s possible for two objects with different identities to be easily distinguished by the system and that two objects with the same identity are considered equal.
If we can’t guarantee this consistency, we’ll run into corrupted data and a lot of headaches.

Entities are important objects of our Domain Model and should be considered from the beginning of our modeling.

Looking at the contexts we designed, each had its own Domain model that served a need.
A fishing rod is simultaneously fishing equipment (Catalog), a performance component (Recommendation), a product for sale (Sales), and a stock item (Inventory), but these are different models, not different views of the same model!

Let’s focus first on the Equipment Catalog Context:

Concern: Manage specifications and compatibility of fishing equipment
Has:
- Fishing Equipment (not "Product")
- Compatibility Rules
- Technical Specifications
- Equipment Categories

We understand better about this when we simulate some conversations and try to absorb knowledge from our Experts, but it’s good to remember once more that this work never ends. The main concern now is being able to “Manage specifications and compatibilities that fishing equipment has”.

So we start to design something at a higher level:

// EquipmentCatalog.Domain

class FishingEquipment
- string EquipmentIdentifier // we need to identify each equipment
- object TechnicalSpecifications // manage the specifications
- object EquipmentCategory // equipment category
- object compatibilityRules // compatibility rules, sounds more like a behavior maybe?
...

Now, do we need to create Entities for all objects we would like to have control over?
Do we really need to be able to identify each object with a unique identity? It seems too much work to have to maintain unique identifiers (or composites) for all of them (and it really is).

Valuable objects

So, for which cases do we want to focus only on values and don’t need to worry about maintaining their uniqueness?
We call them Value Objects, those that carry this characteristic and it’s very important to understand the difference between them and our Entities.

An important characteristic of these objects is to keep them immutable, they need to be created by a constructor and never modified during their lifetime. If you happen to want a new value, you need to create a new object. It’s as simple as that. This way, not having unique identifiers and ensuring they will never change, they can be shared without side effects.

They can have other Value Objects and even references to Entities, but it’s good to always keep them with one concept in mind.

With these two new tools in our fishing toolbox, we can finally venture into some code.
I decided to combine the useful with the pleasant, I’m going to create the examples while studying Effect. I won’t dive deep into anything related to it, the only important thing to know is it’s a TypeScript library with superpowers.

At this moment, we’ll consider 4 types of Equipment: Rods, Reels, Lines and Baits.
I believe they all have attributes that can be shared.

Follow the code here The Fisherman – Pull 1

// domain/EquipmentCatalog/_BaseEquipment.ts
export class BaseEquipment extends Schema.Class<BaseEquipment>("BaseEquipment")({
  id: EquipmentId, // Unique Identifier
  manufacturer: Schema.instanceOf(ManufacturerInfo), // Manufacturer
  modelName: Schema.NonEmptyString, // Name
  partNumber: Schema.NonEmptyString, // Equipment number
  catalogAddedDate: Schema.DateFromSelf // When it was added to the catalog
}) {}

Did you notice that the Manufacturer is possibly an object with other attributes, but let’s keep things simple at this moment, maybe it doesn’t need to have a unique identifier and become an Entity, for now it can just be a Value Object.

// domain/EquipmentCatalog/valueObjects/ManufacturerInfoVo.ts
export class ManufacturerInfo extends Data.TaggedClass("ManufacturerInfo")<{
  readonly name: string
  readonly countryOfOrigin?: string // Country of Origin
  readonly website?: string
}> {}

Now, let’s start modeling what would be our Fishing Rods:

// domain/EquipmentCatalog/entities/FishingRodEntity.ts
export class FishingRod extends BaseEquipment.extend<FishingRod>("FishingRod")(
  power: Schema.instanceOf(RodPower), // Power
  action: Schema.instanceOf(RodAction), // Action
  length: RodLength, // Length
  rodType: Schema.instanceOf(RodType), // Type
  pieces: Schema.Int.pipe(Schema.positive()), // Sections
  materialComposition: Schema.String // Material
...

We have a lot of information here, let’s go the easy way at this moment.
The Experts told us that the Length of a Rod should always be between the range of 4 to 15 feet, so we need to ensure that an invalid state for this attribute is not possible.

https://norrik.com/choosing-a-fishing-rod-which-type-is-best-for-you/

// domain/EquipmentCatalog/valueObjects/Rod/RodLengthVo.ts
export const RodLength = Schema.Number.pipe(
  Schema.between(4, 15, { message: () => "Rod length must be between 4 and 15 feet" }),
  ...

Right, we guarantee that when creating a Rod, it’s impossible to instantiate a Fishing Rod that doesn’t have a Length equal to what we described.

Let’s complicate things a bit now, looking at the notes from our last conversation with the Experts, we had the following rule:
Expert: “An ultra-light power rod doesn’t work well with fast action and, one with heavy power needs at least moderate action…”
You: “Right! And how do we define how a rod has ultra-light or heavy power?”
Expert: “We have ranges of values to represent each type. I’ll give you how we have them registered…”

Right, it’s a compatibility rule that we need to apply regarding the Power of the Fishing Rod, at this moment we can add this rule in the Value Object that represents it:

// domain/EquipmentCatalog/valueObjects/Rod/RodPowerVo.ts
export class RodPower extends Data.TaggedClass("RodPower")<{
  readonly name: string
  readonly powerRating: number
  ...
}> {
  isCompatibleWith(action: RodAction): boolean {
    // Domain rule: Ultra-light power doesn't work well with fast action
    // Power less than or equal to 2 means ultra-light
    if (this.powerRating <= 2 && action.flexPoint === "tip") return false

    // Domain rule: Heavy power needs at least moderate action
    // Power greater than or equal to 8 means heavy
    if (this.powerRating >= 8 && action.flexPoint === "throughout") return false

    return true
  }
}

Now, let’s ensure that this rule is applied when a Fishing Rod is instantiated.

// domain/EquipmentCatalog/entities/FishingRodEntity.ts
export class FishingRod extends BaseEquipment.extend<FishingRod>("FishingRod")(
...
  }).pipe(
    Schema.filter(
      (rod) =>
        rod.power.isCompatibleWith(rod.action) ||
        `Power ${rod.power.name} is not compatible with Action ${rod.action.name}`
    )
  )
) {}

At this point we guarantee that little by little the behaviors expected by our Experts are really being applied in our code.
Besides, it’s a great moment to write some tests, with what until now are valid and invalid combinations, helping in building our rules.
I won’t go into implementation detail here (you can check the repository for that), but just describe what we’re expecting, including these tests could have been written before as acceptance criteria for the rules we were going to implement.

// test/domain/EquipmentCatalog/entities/FishingRod.test.ts
describe("valid combinations", () => {
  describe("with moderate action", () => {
    it("creates rod with compatible ultra-light power", () => { ... })
    it("creates rod with compatible heavy power", () => { ... })
  })
})
describe("invalid combinations", () => {
  it("rejects ultra-light power with fast action", () => { ... })
  it("rejects heavy power with slow action", () => { ... })
})
describe("validation rules", () => {
  it("rejects negative pieces count", () => { ... })
  it("rejects zero pieces count", () => { ... })
})

Together we are stronger

Now let’s enter a different modeling challenge, one related to the lifecycle of Domain Objects. Even though we already have an initial structure and some rules implemented, we still need to improve the ownership of our properties and the boundaries with other Domains.

Even though until now we have only one Domain implemented, it’s already possible to understand that there’s a relationship between it and its Value Objects, and the more associations we have between these Objects and even other Domains, the more complex it will become to maintain consistency while they are “alive” in memory or even saved in a database.

It’s also necessary to be able to impose Invariants, they are those rules that need to be maintained whenever data changes. This is difficult to implement if we have many references to other objects (even though it’s impossible to escape from this).

It’s dangerous to go alone! Take this!

To facilitate this, we have the concept of grouping Entities and Value Objects in an Aggregate and then define boundaries around each of these groups. An Entity will become the root/entry for each Aggregate and ALL access control to objects within this boundary is done through it.

Follow the code now in another PR here The Fisherman – Pull 2

// domain/EquipmentCatalog/entities/FishingRodEntity.ts
export class FishingRod extends Schema.Class<FishingRod>("FishingRod")(
  Schema.Struct({
    // Previous _BaseEquipment properties
    id: EquipmentId,
    manufacturer: Schema.instanceOf(ManufacturerInfo),
    modelName: Schema.NonEmptyString,
    partNumber: Schema.NonEmptyString,
    catalogAddedDate: Schema.DateFromSelf,

    power: Schema.instanceOf(RodPower),
    action: Schema.instanceOf(RodAction),
    length: RodLength,
    rodType: Schema.instanceOf(RodType),
    pieces: Schema.Int.pipe(Schema.positive()),
    materialComposition: Schema.String,
    lastModified: Schema.DateFromSelf,
    version: Schema.Int.pipe(Schema.positive())
  })

Did you notice that now all the attributes from _BaseEquipment are also back? We have to avoid abstractions trying to predict future “reuses” for the code, because this way, we’re distancing ourselves from our ubiquitous language. If looking at the code, one of our Experts read through this class, possibly it wouldn’t mean anything, and of course, we can have levels of abstractions to be able to share code that really makes sense. In this case, I was trying to predict the next steps a bit, so let’s go back.

// domain/EquipmentCatalog/entities/FishingRodEntity.ts
static create(data: {
  ...
}): Effect.Effect<FishingRod, EquipmentValidationError> {
  const now = new Date()

  const errors: Array<string> = []

  if (!data.power.isCompatibleWith(data.action)) {
    errors.push(`Power ${data.power.name} is not compatible with Action ${data.action.name}`)
  }

  if (data.pieces < 1 || data.pieces > 4) {
    errors.push("Rod pieces must be between 1 and 4")
  }

  if (errors.length > 0) {
    return Effect.fail(
      new EquipmentValidationError({
        equipmentId: data.id,
        errors
      })
    )
  }

  return Effect.succeed(
    new FishingRod({
      id: data.id,
      manufacturer: data.manufacturer,
      modelName: data.modelName,
      partNumber: data.partNumber,
      catalogAddedDate: now,
      power: data.power,
      action: data.action,
      length: data.length,
      rodType: data.rodType,
      pieces: data.pieces,
      materialComposition: data.materialComposition,
      lastModified: now,
      version: 1
    })
  )
}

Now we really centralized the creation of our Entity and avoid escaping our control, even though before we had some minimal validation in the instance, we improved even more with specific methods with proper exposure.

Now, if we need to make any updates, we also need to maintain the same consistency. Our Expert says something like “When I change the power of a Rod, I need to make sure it still works with the existing action and doesn’t break any equipment combinations! Oh, significant power changes can also affect structural integrity!”

// domain/EquipmentCatalog/entities/FishingRodEntity.ts
changePower(newPower: RodPower): Effect.Effect<FishingRod, IncompatibleSpecificationError> {
  // New power must be compatible with existing action
  if (!newPower.isCompatibleWith(this.action)) {
    return Effect.fail(
      new IncompatibleSpecificationError({
        equipmentId: this.id,
        reason: `New power ${newPower.name} is not compatible with action ${this.action.name}`
      })
    )
  }

  // Significant power changes might affect structural integrity
  const powerDifference =
  Math.abs(newPower.powerRating - this.power.powerRating)
  if (powerDifference > 3) {
    return Effect.fail(
      new IncompatibleSpecificationError({
        equipmentId: this.id,
        reason: `Power change too dramatic: from ${this.power.name} to ${newPower.name}`
      })
    )
  }

  return Effect.succeed(
    new FishingRod({
      ...this,
      power: newPower,
      lastModified: new Date(),
      version: this.version + 1
    })
  )
}

At this point in our implementation, we ensure that it’s not possible to update the Power without going through the necessary rules and also guarantee that no invalid state for our Fishing Rod exists.
It’s also good to remember that the tests need to be updated with the new structure and methods.

What’s in the box?

Is this the same fish I left here?

Until now, we’ve only worked with part of the lifecycle of our Entities and Objects, but we still don’t have a way to keep them stored in a permanent state in a database or other form of persistence.

We need a means of acquiring references to our Domain Objects without adding more responsibilities to what we already have.
We’ll use a Repository, whose objective is to encapsulate all the necessary logic we need in a new part that deals with infrastructure.

To have a more practical and simple example, I created only a repository that will keep our Domain Object in memory.
The Effect.gen is like a “factory” that creates our repository, it’s similar to async/await, but more powerful for dealing with errors.

// infrastructure/EquipmentCatalog/repositories/InMemoryFishingRodRepository.ts
export const makeFishingRodRepository = Effect.gen(function* () {
  const rods = yield* Ref.make(new Map<EquipmentId, FishingRod>())

  const findById = (id: EquipmentId): Effect.Effect<FishingRod, EquipmentNotFoundError> =>
    pipe(
      Ref.get(rods), // "Open the box" and see what's inside
      Effect.flatMap((rodMap) => { // "If managed to open..."
        const rod = rodMap.get(id) // Look for the Fishing Rod
        return rod ? Effect.succeed(rod) : Effect.fail(new EquipmentNotFoundError({ equipmentId: id })) // "Didn't find it, error!"
      })
    )

  const save = (rod: FishingRod): Effect.Effect<void, EquipmentValidationError> =>
    pipe(
      Ref.update(rods, (rodMap) => new Map(rodMap).set(rod.id, rod)), // Add/update
      Effect.asVoid
    )

  return {
    findById,
    save
  } as const
})

All the Entities, Objects and rules we’ve implemented so far haven’t had their concepts taken from anywhere other than our ubiquitous language. Nothing was guessed or designed by coincidence, our implementation and the model we’re implementing should still coincide.

And what we have implemented so far can be better visualized here:

That’s it for today. I want to make it clear that many decisions here were made thinking about the clearest way for the examples and certainly several concepts and understandings can be revisited as we advance in our studies.

All the reflections here were while I was consuming these materials:

See you on GitHub! 🚀

We want to work with you. Check out our Services page!