Devlog #4: Hidden Coupling

This is the fourth devlog in the series documenting the extraction of a Profiles service from our monolith. You can read about The Profiles RFC, Implementing Branch by Abstraction, and Event Inconsistencies in the previous posts.


I'm extracting a bounded context from a legacy codebase. The work is slow, the code is messy and tests are sparse.

Before I refactor code, I write tests. I need that feedback loop. I need to know when I break something. Without tests, I'm flying blind.

I found a chunk of code related to the ProfileUpdated event with almost no unit test coverage. Since this event has been in production for over a decade, writing that first test should be straightforward, right?

It wasn't.

In the previous post, I talked about how events in this codebase were coupled to entities. Events held references to entities and generated their payload only when being published. I changed that. Events are now immutable. But, that change exposed something I didn't expect.

If you haven't read that post on Event Inconsistencies, I'd recommend checking it out first. The short version: I stopped events holding reference to entities and removed lazy payload generation to prevent data inconsistencies.

That decision revealed a hidden coupling that had been lurking in the code for years.

You're about to see code from a legacy system. Older PHP syntax. Annotations instead of attributes. Strange coupling and design decisions that make your eyebrow raise. That's just the reality of working with systems built over many years by many different hands.

#The Problem

The ProfileUpdated event constructor looked relatively innocent:

class ProfileUpdated
{
private DateTime $happenedAt;
public function __construct(Profile $profile)
{
$this->happenedAt = $profile->getUpdatedAt();
}
}

The event needs a timestamp. The entity has a timestamp. The event asks the entity for it. This pattern existed all over the codebase.

I started writing a test by creating a Profile entity and triggering some behaviour to record an instance of the ProfileUpdated event. Next, I made some simple assertions. One of them ensuring the generated payload was what I expected.

The test failed.

The happenedAt value was null, which didn't make much sense, so I had a look at the implementation of getUpdatedAt():

trait Timestamps
{
/**
* @ORM\Column(name="updated_at", type="datetime", nullable=false)
* @var \DateTime
*/
protected $updatedAt;
public function getUpdatedAt()
{
return $this->updatedAt;
}
/** @ORM\PreUpdate */
public function doUpdateUpdatedAtField()
{
$this->updatedAt = new \DateTime();
}
}

Interesting.

This Timestamps trait is mixed into the entity.

The updatedAt property is being managed by the ORM. The @ORM\PreUpdate annotation tells the ORM to call doUpdateUpdatedAtField() at the beginning of the persistence cycle.

This means the timestamp gets populated when the ORM is about to persist the entity. Before that, calling getUpdatedAt() will return null.

Since unit tests don't touch the database - there is no persistence cycle. Our event gets null from getUpdatedAt() resulting in the payload being invalid within the test.

The event is coupled to the database through the ORM's lifecycle hooks.

#Why Nobody Noticed

This coupling has lived in the code for years. Nobody caught it.

Why?

The old event flow masked the problem. Events held references to entities. They didn't generate their payloads at creation. They waited. The payload was generated when getPayload() was called, which happened after persistence.

The old flow looked like this:

  • Behavior happens
  • Event is recorded, holding reference to entity
  • Entity persistence starts
    • ORM calls hooks
    • The updatedAt property gets populated
    • Entity persistence completes
  • Event dispatching begins
    • Payload is generated for each event
    • Event asks entity for updatedAt
    • Value exists. Everything is fine

By the time the event asked for the timestamp, the entity had already been through the persistence cycle. The hook had fired. The property was populated. The code worked.

The coupling was invisible.

When refactoring to fix the event inconsistencies, the process was changed. Events became immutable. Their values do not change after being created and certainly don't wait for ORM hooks. The event needs happenedAt as soon as it's created.

The coupling was now visible.

#The Fix

The solution is quite simple. The event creates its own timestamp:

class ProfileUpdated
{
private DateTimeImmutable $happenedAt;
public function __construct()
{
$this->happenedAt = new \DateTimeImmutable();
}
}

No more Profile parameter. It no longer asks the entity for anything. The event knows when it was created and owns that information.

This completes the decoupling I started in the previous post with the event no longer holding a reference to the entity.

I considered other approaches. I could have passed the timestamp as a constructor parameter. But that pushes the responsibility outward. Something on the outside would need to create the timestamp and pass it in. Creating it inside the event encapsulates the knowledge. Nothing on the outside needs to care. I also took the opportunity to swap the type to use DateTimeImmutable instead of using what the trait returned.

This removes the entire dependency chain. No entity. No trait. No ORM hooks. No database lifecycle. I can create an event by passing in the required data to it's constructor. That's it.

I can now write a unit test that creates the event and verifies its behavior without touching infrastructure. No database. No mocks. No stubs. Just the event.

And, the event is immutable. Once created, its state no longer changes. The payload it would generate straight after creation will be the same payload it would generate later. No lazy evaluation. No waiting for the ORM. No surprises.

That's what I needed in order to continue refactoring. A fast feedback loop that exposes problems immediately, so I can build confidence my changes aren't breaking something.

The actual implementation in this codebase is more complex than this simplified example. The event carries more data than just a timestamp. But the principle is the same. The event should own its data. It shouldn't reach outside itself and it certainly shouldn't depend on infrastructure.

#What All This Means

This isn't just about a null value. It's about the consequences of small decisions.

Someone decided to use a trait for timestamps. Someone decided to let the ORM manage those timestamps. Someone decided the event should ask the entity for its updatedAt value. Someone decided not to write tests.

Each decision seemed reasonable at the time. A trait for common behavior? Smart reuse. Let the ORM handle timestamps? One less thing to manage manually. Ask the entity for the value? It has the data. Skip tests? There's a deadline.

None of these decisions were catastrophically bad. They were normal. Could even seem pragmatic. The kind of choices developers make every day.

But they compounded. The trait introduced ORM coupling. The ORM coupling meant values didn't exist until persistence. Asking the entity meant inheriting that coupling. No tests meant nobody noticed for a decade.

That's what creates legacy code. Not one big mistake. Lots of small decisions that seem fine in isolation but create problems when combined.

The ProfileUpdated event is an integration event. It carries a full representation of the profile's state so other services can react without coupling to our internal domain events. Other services don't care that an address changed or an email was verified. They want to know the profile was updated and what it looks like now.

That event should know when it was created. Not when the entity was persisted. Not when the ORM decided to populate a field. When the behavior happened. When the event came into existence.

The event asking the entity for that timestamp created coupling that reached through the entity, into a trait, through ORM annotations, and all the way to the database. A simple method call was hiding quite a complex dependency chain.

The getter looked harmless. You're asking an object for its data. But the object doesn't own that data. The ORM does. The entity is just passing through whatever the infrastructure layer decides to provide.

That's the coupling. Not just to the entity. To the database. To the ORM lifecycle. To infrastructure concerns that the domain layer shouldn't know about.

I could have faked the entity in my test. I could have pre-populated the value. The test would pass. The coupling would stay hidden. I'd have tested my mock, not my system.

Writing the test exposed the problem. Not because tests always catch coupling. They don't. But writing tests that avoid fakes and mocks forces you to create objects the way your system actually creates them. That's when hidden dependencies become visible.

This is the value of dealing with legacy code. You get to see the consequences of decisions. You learn what compounds. You learn what to avoid. You grow.

The code has been running successfully for over a decade. It made the business work. That's worth respecting. Now we have the opportunity to learn from it and make it better.


Have you discovered similar hidden coupling in your legacy systems? Reach out and let me know what infrastructure dependencies you've found lurking in your domain layer.