In the previous devlog, we introduced the project through an RFC. Now we can explore the technical approach for extracting a service from a monolith.
We can consider two well-established patterns to help us:
Strangler Pattern: Arguably the most popular pattern. Typically replaces an existing services by redirecting API calls at their boundaries.
Branch by Abstraction: Part of Trunk-based development. Focuses on the creation of an abstraction to enable gradual migration between implementations.
As raised in the RFC, we don't have a clear boundary to apply the Strangler pattern. We need to do significant groundwork first. Additionally, we want to discover the boundary incrementally while migrating functionality from the monolith to the new Bounded Context.
Since we’re not redirecting requests from the edge of the service, and instead working within deeply nested and tangled code, Branch by Abstraction is a better fit - not to mention the fact that we will also be leveraging many other techniques from Trunk-based Development.
In fact, these patterns share many similarities, and you could apply many of the techniques covered in this post when implementing the Strangler pattern.
Since the Profile Service doesn't yet exist, we have the opportunity to define it from scratch by introducing the ProfileService
interface:
1interface ProfileService {2}
Let's select a piece of functionality that will fall within our Profiles boundary. For this example, we'll implement a simplified version of changeName()
:
1interface ProfileService {2 public function changeName(3 string $userId,4 string $firstName,5 string $lastName6 ): void;7}
This is how we will establish our boundary. First we’ll describe the behaviour, then we move the existing code behind this interface. This will give us a relatively clean starting point to build our new implementation later.
Our first implementation will be the LegacyProfileService
. This is where we will move our existing code which will be replaced once we have a new bounded context:
1final class LegacyProfileService implements ProfileService2{3 public function __construct(4 private UserRepository $users5 ) {6 }7
8 public function changeName(9 string $userId,10 string $firstName,11 string $lastName12 ): void {13 $user = $this->users->findById($userId);14 $user->changeName($firstName, $lastName);15 $this->users->add($user);16 }17}
This simplified example demonstrates the code we use to change a person's name. Now that we've encapsulated this functionality within the LegacyProfileService
, other parts of our codebase can simply inject the service and call the method:
1final readonly class ExampleCode2{3 public function __constructor(4 private ProfileService $profiles5 ) {6 }7
8 public function example(): void9 {10 $this->profiles->changeName(11 userId: '4cm3',12 firstName: 'Mickey',13 lastName: 'Mouse'14 );15 }16}
By calling the ProfileService
, our code no longer needs to know how a name gets changed. All those implementation details are neatly tucked away in whatever class implements the ProfileService
interface.
We'll continue this process throughout the codebase, updating everywhere names are changed to use ProfileService
instead of calling a method on the User
entity.
At this point, our application works exactly the same as before - but we have added a layer of abstraction that will make future changes much easier.
Once our code is flowing through the interface, we can introduce the ModernProfileService
. This new implementation will mirror the functionality of the Legacy service.
It's important to note that, at this stage, we are still performing a true refactor and preserving the application's current behaviour.
1final class ModernProfileService implements ProfileService2{3 public function __construct(4 private CommandBus $commandBus5 ) {6 }7
8 public function changeName(9 string $userId,10 string $firstName,11 string $lastName12 ): void {13 $command = new ChangeName(14 userId: $userId,15 firstName: $firstName,16 lastName: $lastName17 );18
19 $this->commandBus->handle($command);20 }21}
We can now begin invoking code within our bounded context.
The parallel approach of a Modern and Legacy implementation running side-by-side allow us to gradually migrate functionality. It enables a seamless transition to our new code, when it’s stable, with rollback capabilities - if needed.
The specific details of changeName()
just serve as a simple example. It shows a Command being passed through a CommandBus which will reach a Handler within in our new context.
We happen to be applying CQRS within our new boundary, but that's another topic - let me know if you'd like to learn more about it!
With two implementations of the same functionality running in parallel - things are about to get interesting!
If our Legacy and Modern implementations are interchangeable without causing regressions, we might be eager to migrate more functionality. However, if we do, we will face significant challenges.
Let’s explore what those are and how we can address them.
So far, things are quite simple. But, as soon as we continue with our refactoring efforts, we will soon start to notice some code smells. Imagine we next choose to refactor ways in which the date of birth can be updated:
1interface ProfileService {2 public function changeName(3 string $userId,4 string $firstName,5 string $lastName6 ): void;7
8 public function changeDoB(9 string $userId,10 DateTimeImmutable $date11 ): void;12}
We will need to update our implementations. The Legacy version will be first to encapsulate the existing behaviour:
1final class LegacyProfileService implements ProfileService2{3 public function __construct(4 private UserRepository $users5 ) {6 }7
8 public function changeName(9 string $userId,10 string $firstName,11 string $lastName12 ): void {13 $user = $this->users->findById($userId);14 $user->changeName($firstName, $lastName);15 $this->users->add($user);16 }17
18 public function changeDoB(19 string $userId,20 DateTimeImmutable $date21 ): void {22 $user = $this->users->findById($userId);23 $user->changeDoB($date);24 $this->users->add($user);25 }26}
At this point, we will find ourselves violating the Liskov Substitution Principle.
LSP suggests, if we implement an interface, we must be able to fully satisfy its contract. This means the ModernProfileService
needs to be able to handle all the methods defined in the interface without breaking the application's functionality.
But we’re not ready to work on the reimplementation, yet.
1final class ModernProfileService implements ProfileService2{3 public function __construct(4 private CommandBus $commandBus5 ) {6 }7
8 public function changeName(9 string $userId,10 string $firstName,11 string $lastName12 ): void {13 $command = new ChangeName(14 userId: $userId,15 firstName: $firstName,16 lastName: $lastName17 );18
19 $this->commandBus->handle($command);20 }21
22 public function changeDoB(23 string $userId,24 DateTimeImmutable $date25 ): void {26 throw new RuntimeException('Unimplemented Functionality');27 }28}
Before we can move from Encapsulation to Reimplementation, we must first update all existing code to use the ProfileService
for changing a person's date of birth. In the meantime, we're forced to add the new method to the Modern implementation and throw an exception to avoid unimplemented method errors.
This creates a major problem: if even one method is unimplemented, we can't use the Modern service at all until that method is complete. We would need to switch back to the Legacy service, finish the new implementation, and then switch to the Modern service again.
This means we cannot run both implementations side by side, dark-deploy the new implementation, or perform iterative cleanup.
This approach would prevent us from iteratively delivering new functionality. While these examples are simple, the real-world scenarios are much more complex and challenging, with cross-cutting concerns requiring significant time and coordination across teams.
By thinking forward to the next piece of functionality, we can clearly see this approach would be insufficient. Switching everything at once with an all-or-nothing approach isn't ideal.
If switching implementations at a high level (such as within the service provider when binding into the container) is too inflexible - what alternatives do we have?
Feature flags are an essential tool in Trunk-Based Development, particularly when using Branch by Abstraction (BbA). We already extensively use feature flags across our systems, so this would align well with our existing practices. These flags act as switches we can toggle while the app is running - no deployment needed. This enables gradual changes and easy rollbacks if issues arise.
Feature flags alone aren't enough - we need a solution that uses them effectively. Switching the entire implementation at once prevents iterative delivery and makes work harder to break down into manageable chunks. Both services would need to be fully functional before switching, which increases risk. This approach forces us to maintain everything longer than necessary, delaying cleanup and preventing the iterative approach we want.
We need to choose between the Legacy and Modern implementations at a more granular level.
The CompositeProfileService
can route requests between Legacy and Modern implementations using feature flags. It implements the ProfileService
interface and, with both services and a FeatureFlagService
injected, controls which implementation handles each request at runtime:
1final readonly class CompositeProfileService implements ProfileService2{3 public function __construct(4 private LegacyProfileService $legacy,5 private ModernProfileService $modern,6 private FeatureFlagService $flags7 ) {}8
9 public function changeName(10 string $userId,11 string $firstName,12 string $lastName13 ): void {14 if ($this->flags->enabled('profilesDecoupling.changeName')) {15 $this->modern->changeName($userId, $firstName, $lastName);16 return;17 }18
19 $this->legacy->changeName($userId, $firstName, $lastName);20 }21}
By checking feature flags at the method level, we can migrate functionality piece by piece and validate each change before moving forward. This allows us to horizontally slice our work - migrating individual behaviors rather than waiting for the entire service to be complete.
This granular approach lets us deploy new implementations to production without exposing them to users (dark launching). We can validate changes in a real environment and roll them out gradually once we're confident they work, helping us ship faster while maintaining safety through controlled releases.
No longer do we require an all-or-nothing switch.
The CompositeProfileService
isn't just a routing layer or where we’ll manage the transition between old and new implementations - it’s where we add valuable features like monitoring, logging, and telemetry to track performance and debug issues. It's also where we utilise temporary adapters or anti-corruption layers when needed.
What’s most interesting is being able to increase resilience by adding automatic fallbacks:
Feature flags let us quickly roll back changes, but we can take this safety net even further with automatic fallbacks. One of the key advantages of Branch by Abstraction is running both old and new implementations side by side.
When something goes wrong with the new code (and we've ensured our operations are atomic), we can seamlessly fall back to the tried-and-tested implementation. For example, if our new changeName
implementation throws an exception, we can catch that error and retry using our reliable legacy code. This provides an extra layer of reliability during the transition.
Together, these features create a robust system for gradually migrating functionality while maintaining stability.
With the introduction of CompositeProfileService
, both Legacy and Modern implementations no longer need to implement the interface, since the Composite service satisfies it. This allows us to remove empty or redundant methods that were previously required, simplifying both implementations and making future modifications easier.
This approach provides flexibility as we continue to uncover new aspects of the domain. The CompositeProfileService
lets us expand the project's scope by adding new methods to the interface and their implementations, without disrupting existing functionality.
As the stability of our new implementations are gradually proven, we can begin removing the old code. While this will remove our ability to automatically fall back to legacy implementations, by this point we should have high confidence in the new code through extensive testing and controlled rollouts.
Fortunately, our ProfileService
interface is satisfied by composing our Legacy and Modern services, which means we can start deleting the legacy methods from our service implementations:
1final class LegacyProfileService2{3 public function __construct(4 private UserRepository $users5 ) {6 }7
8 public function changeName(9 string $userId,10 string $firstName,11 string $lastName12 ): void {13 $user = $this->users->findById($userId);14 $user->changeName($firstName, $lastName);15 $this->users->add($user);16 }17
18 public function changeDoB(string $userId, DateTimeImmutable $date): void19 {20 $user = $this->users->findById($userId);21 $user->changeDoB($date);22 $this->users->add($user);23 }24}
This would not be possible if LegacyProfileService
was implementing our ProfileService
interface.
With the legacy implementation now removed, we will remove the feature flag checks from the CompositeProfileService
and simply delegate directly to the modern implementation:
1final class CompositeProfileService implements ProfileService2{3 public function __construct(4 private readonly LegacyProfileService $legacy,5 private readonly ModernProfileService $modern,6 private readonly FeatureFlagService $flags7 ) {}8
9 public function changeName(10 string $userId,11 string $firstName,12 string $lastName13 ): void {14 if ($this->flags->enabled('profilesDecoupling.changeName')) {15 $this->modern->changeName($userId, $firstName, $lastName);16 return;17 }18
19 $this->legacy->changeName($userId, $firstName, $lastName);20 }21}
Finally, the most satisfying moment in any modernization project - bidding farewell to the legacy code that has served us well but whose time has come to an end:
1class User2{3 public function changeName(string $firstName, string $lastName): void {4 // old code5 }6}
This article has covered a lot of ground, but I hope it provides a clear picture of our approach.
We explored the implementation of Branch by Abstraction (BbA) as a strategy for gradually modernizing legacy code.
By using Branch by Abstraction with Feature Flags and a composite service, we've created a robust workflow for modernizing our codebase while minimizing risk and maintaining operational stability. This pattern enables us to work iteratively, validate our changes safely, and gradually transition to our new architecture without disrupting existing functionality.