Composing responsibilities to reduce coupling and improve tests' maintainability
Published by Manuel Rivero on 22/02/2026
Introduction.
We’d like to show an example of how composing responsibilities reduced the coupling between tests and production code and enabled simplifications, both in tests and production code, which led to more maintainable tests.
The original code.
This is the original code of the AcmeCompanyApi class.
| class AcmeCompanyApi implements CompanyApi { | |
| constructor( | |
| private readonly forGettingCauses: ForGettingCauses, | |
| private readonly forOpeningClaim: ForOpeningClaim, | |
| private readonly authTokenRetriever: AuthTokenRetriever) { | |
| } | |
| async open(claim: Claim): Promise<OpeningResult> { | |
| try { | |
| const token = await this.authTokenRetriever.retrieveToken(); | |
| const causes = await this.forGettingCauses.getAllFor(claim, token); | |
| const cause = this.findCauseInClaim(causes, claim); | |
| const referenceInCompany = await this.forOpeningClaim.open(claim, cause, token); | |
| return OpeningResult.successful(referenceInCompany, claim); | |
| } catch (e) { | |
| if (e instanceof CannotRetrieveTokenError) { | |
| return OpeningResult.failed(claim, 'Acme API: failure retrieving token'); | |
| } | |
| if (e instanceof CannotGetCausesError) { | |
| return OpeningResult.failed(claim, 'Acme API: failure getting claim causes'); | |
| } | |
| if (e instanceof CannotOpenClaimError) { | |
| return OpeningResult.failed( | |
| claim, `Acme API: cannot open claim ${claim.id()}` | |
| ); | |
| } | |
| if (e instanceof CannotFindMatchingCauseError) { | |
| return OpeningResult.failed( | |
| claim, `Acme API: cannot find cause for claim ${claim.id()}` | |
| ); | |
| } | |
| return OpeningResult.failed( | |
| claim, `Acme API: ${e.message}` | |
| ); | |
| } | |
| } | |
| private findCauseInClaim(causes: Cause[], claim: Claim): Cause { | |
| let foundCause = causes.find((c) => c.causeCode === claim.causeCode()); | |
| if (!foundCause) { | |
| throw new CannotFindMatchingCauseError(); | |
| } | |
| return foundCause; | |
| } | |
| } |
AcmeCompanyApi had the responsibility of opening a claim in Acme insurance company. To do that, it was coordinating interactions with three endpoints that were required to open a claim. AcmeCompanyApi was also in charge of handling all the possible exceptions that those interactions can throw.
We had a broad integration test written using Wiremock for the happy path of AcmeCompanyApi that was virtualizing the three endpoints. We also had focused integration tests for each endpoint also using Wiremock to check that all possible errors were mapped to domain exceptions.
Since test-driving the error handling in AcmeCompanyApi with broad integration tests felt too cumbersome, we decided to introduce three interfaces (ForGettingCauses, ForOpeningClaim and AuthTokenRetriever) to simulate problems in the interactions with the endpoints.
Notice that these interfaces were introduced only to make testing the error handling logic easier and that they had only one implementation (AcmeCausesEndpoint, AcmeClaimOpeningEndpoint and AcmeAuthTokenRetriever, respectively).
These are the initial tests of AcmeCompanyApi’s error handling logic:
| // some imports | |
| describe('AcmeCompanyApi', () => { | |
| // some declarations | |
| beforeEach(() => { | |
| forGettingCauses = { | |
| getAllFor: jest.fn<Promise<Cause[]>, [Claim, AuthToken]>() | |
| }; | |
| forOpeningClaim = { | |
| open: jest.fn<Promise<string>, [Claim, Cause, AuthToken]>() | |
| }; | |
| authTokenRetriever = { | |
| retrieveToken: jest.fn<Promise<AuthToken>, []>() | |
| }; | |
| authToken = {token: 'mi_token', type: 'Bearer'}; | |
| api = createApi(forGettingCauses, forOpeningClaim, authTokenRetriever); | |
| claim = aClaim(causeCodeInClaim); | |
| }); | |
| it('should fail when token retrieval throws error', async () => { | |
| when(authTokenRetriever.retrieveToken) | |
| .calledWith() | |
| .mockRejectedValue(new CannotRetrieveTokenError('Cannot retrieve token')); | |
| const result = await api.open(claim); | |
| expect(result).toEqual(new FailedOpening( | |
| claim, | |
| 'Acme API: failure retrieving token' | |
| )); | |
| }); | |
| it('should fail when getting causes throws error', async () => { | |
| when(authTokenRetriever.retrieveToken) | |
| .calledWith() | |
| .mockResolvedValue(authToken); | |
| when(forGettingCauses.getAllFor) | |
| .calledWith(claim, authToken) | |
| .mockRejectedValue(new CannotGetCausesError('Cannot retrieve causes')); | |
| const result = await api.open(claim); | |
| expect(result).toEqual(new FailedOpening( | |
| claim, | |
| 'Acme API: failure getting claim causes' | |
| )); | |
| }); | |
| it('should fail when finding matching cause throws error', async () => { | |
| const cause = new Cause('code not used in claim'); | |
| when(authTokenRetriever.retrieveToken) | |
| .calledWith() | |
| .mockResolvedValue(authToken); | |
| when(forGettingCauses.getAllFor) | |
| .calledWith(claim, authToken) | |
| .mockResolvedValue([cause]); | |
| const result = await api.open(claim); | |
| expect(result).toEqual(new FailedOpening( | |
| claim, | |
| 'Acme API: cannot find cause for claim 123456789' | |
| )); | |
| }); | |
| it('should fail when opening claim throws error', async () => { | |
| const matchingCause = new Cause(causeCodeInClaim); | |
| when(authTokenRetriever.retrieveToken) | |
| .calledWith() | |
| .mockResolvedValue(authToken); | |
| when(forGettingCauses.getAllFor) | |
| .calledWith(claim, authToken) | |
| .mockResolvedValue([matchingCause]); | |
| when(forOpeningClaim.open) | |
| .calledWith(claim, matchingCause, authToken) | |
| .mockRejectedValue(new CannotOpenClaimError('Cannot open claim')); | |
| const result = await api.open(claim); | |
| expect(result).toEqual(new FailedOpening( | |
| claim, | |
| 'Acme API: cannot open claim 123456789' | |
| )); | |
| }); | |
| // some helper functions | |
| }); |
The problem with these tests was that they had a low structure-insensitivity because they are coupled toForGettingCauses, ForOpeningClaim and AuthTokenRetriever,
and any change in those interfaces would force to change the tests of the error handling logic, even in cases in which its behaviour hadn’t changed.
How can we enhance the structure-insensitivity of tests for error handling logic while simultaneously avoiding having to write cumbersome broad integration tests?
The real problem: poor separation of concerns.
We traced the origin of the problem to AcmeCompanyApi having too many responsibilities:
- Opening a claim.
- Handling all the possible exceptions that could be raised and mapping them to an adequate
OpeningResult.
We decided to separate those two responsibilities by introducing a decorator of the CompanyApi
that would be in charge of handling the errors, that we could compose with a new version of AcmeCompanyApi, only responsible for opening a claim.
We used AI assistance to introduce this decorator, and it went quite well. We’ll explain the process in a future post. This post focuses only on how separating responsibilities reduced coupling between tests and production code, and thus, improved the tests’ maintainability.
The code after introducing the decorator.
This is the resulting code of the AcmeCompanyApi class after introducing the decorator:
| class AcmeCompanyApi implements CompanyApi { | |
| constructor( | |
| private readonly forGettingCauses: ForGettingCauses, | |
| private readonly forOpeningClaim: ForOpeningClaim, | |
| private readonly authTokenRetriever: AuthTokenRetriever) { | |
| } | |
| async open(claim: Claim): Promise<OpeningResult> { | |
| const token = await this.authTokenRetriever.retrieveToken(); | |
| const causes = await this.forGettingCauses.getAllFor(claim, token); | |
| const cause = this.findCauseInClaim(causes, claim); | |
| const referenceInCompany = await this.forOpeningClaim.open(claim, cause, token); | |
| return OpeningResult.successful(referenceInCompany, claim); | |
| } | |
| private findCauseInClaim(causes: Cause[], claim: Claim): Cause { | |
| let foundCause = causes.find((c) => c.causeCode === claim.causeCode()); | |
| if (!foundCause) { | |
| throw new CannotFindMatchingCauseError(); | |
| } | |
| return foundCause; | |
| } | |
| } |
Notice how there’s no error handling logic left.
This is the code of the new decorator, WithErrorHandlingCompanyApi, in which we moved the error handling logic:
| class WithErrorHandlingCompanyApi implements CompanyApi { | |
| constructor(private readonly decoratedApi: CompanyApi) { | |
| } | |
| async open(claim: Claim): Promise<OpeningResult> { | |
| try { | |
| return await this.decoratedApi.open(claim); | |
| } catch (e) { | |
| if (e instanceof CannotRetrieveTokenError) { | |
| return OpeningResult.failed(claim, 'Acme API: failure retrieving token'); | |
| } | |
| if (e instanceof CannotGetCausesError) { | |
| return OpeningResult.failed(claim, 'Acme API: failure getting claim causes'); | |
| } | |
| if (e instanceof CannotOpenClaimError) { | |
| return OpeningResult.failed( | |
| claim, `Acme API: cannot open claim ${claim.id()}` | |
| ); | |
| } | |
| if (e instanceof CannotFindMatchingCauseError) { | |
| return OpeningResult.failed( | |
| claim, `Acme API: cannot find cause for claim ${claim.id()}` | |
| ); | |
| } | |
| return OpeningResult.failed( | |
| claim, `Acme API: ${e.message}` | |
| ); | |
| } | |
| } | |
| } |
The simplified tests of the error handling logic are now only coupled to the CompanyApi interface.
Remember that with the previous design these tests were coupled to three interfaces which had only one implementation each (ForGettingCauses, ForOpeningClaim and AuthTokenRetriever).
| describe('AcmeCompanyApi', () => { | |
| // some declarations | |
| beforeEach(() => { | |
| decoratedApi = { | |
| open: jest.fn<Promise<OpeningResult>, [Claim]>() | |
| }; | |
| api = new WithErrorHandlingCompanyApi(decoratedApi); | |
| claim = aClaim(causeCodeInClaim); | |
| }); | |
| it('should fail when token retrieval throws error', async () => { | |
| when(decoratedApi.open) | |
| .calledWith(claim) | |
| .mockImplementation((_) => { | |
| throw new CannotRetrieveTokenError('Cannot retrieve token') | |
| }); | |
| const result = await api.open(claim); | |
| expect(result).toEqual(new FailedOpening( | |
| claim, | |
| 'Acme API: failure retrieving token' | |
| )); | |
| }); | |
| it('should fail when getting causes throws error', async () => { | |
| when(decoratedApi.open) | |
| .calledWith(claim) | |
| .mockImplementation((_) => { | |
| throw new CannotGetCausesError('Cannot retrieve causes') | |
| }); | |
| const result = await api.open(claim); | |
| expect(result).toEqual(new FailedOpening( | |
| claim, | |
| 'Acme API: failure getting claim causes' | |
| )); | |
| }); | |
| it('should fail when finding matching cause throws error', async () => { | |
| when(decoratedApi.open) | |
| .calledWith(claim) | |
| .mockImplementation((_) => { | |
| throw new CannotFindMatchingCauseError() | |
| }); | |
| const result = await api.open(claim); | |
| expect(result).toEqual(new FailedOpening( | |
| claim, | |
| 'Acme API: cannot find cause for claim 123456789' | |
| )); | |
| }); | |
| it('should fail when opening claim throws error', async () => { | |
| when(decoratedApi.open) | |
| .calledWith(claim) | |
| .mockImplementation((_) => { | |
| throw new CannotOpenClaimError('Cannot open claim') | |
| }); | |
| const result = await api.open(claim); | |
| expect(result).toEqual(new FailedOpening( | |
| claim, | |
| 'Acme API: cannot open claim 123456789' | |
| )); | |
| }); | |
| it('should fail when an unknown error occurs', async () => { | |
| const unknownError = new Error('Unexpected error'); | |
| when(decoratedApi.open) | |
| .calledWith(claim) | |
| .mockImplementation((_) => { | |
| throw unknownError; | |
| }); | |
| const result = await api.open(claim); | |
| expect(result).toEqual(new FailedOpening( | |
| claim, | |
| 'Acme API: Unexpected error' | |
| )); | |
| }); | |
| function aClaim(causeCode: string): Claim { | |
| return new Claim( | |
| '123456789', | |
| '123456789', | |
| new Date('2021-01-01'), | |
| 'Test claim', | |
| new Customer('Pepe', '+34668522001'), | |
| causeCode | |
| ); | |
| } | |
| }); |
These tests were also simplified with AI assistance.
Since the tests were not coupled to these interfaces any more, we materialised[1] those three peers of AcmeCompanyApi.
This is the resulting code of AcmeCompanyApi using internals instead of peers:
| class AcmeCompanyApi implements CompanyApi { | |
| private readonly forGettingCauses: ForGettingCauses; | |
| private readonly forOpeningClaim: ForOpeningClaim; | |
| private readonly authTokenRetriever: AuthTokenRetriever; | |
| constructor(config: AcmeApiConfig) { | |
| this.forGettingCauses = new AcmeCausesEndpoint(config); | |
| this.forOpeningClaim = new AcmeClaimOpeningEndpoint(config); | |
| this.authTokenRetriever = new AcmeAuthTokenRetriever(config); | |
| } | |
| async open(claim: Claim): Promise<OpeningResult> { | |
| const token = await this.authTokenRetriever.retrieveToken(); | |
| const causes = await this.forGettingCauses.getAllFor(claim, token); | |
| const cause = this.findCauseInClaim(causes, claim); | |
| const referenceInCompany = await this.forOpeningClaim.open(claim, cause, token); | |
| return OpeningResult.successful(referenceInCompany, claim); | |
| } | |
| private findCauseInClaim(causes: Cause[], claim: Claim): Cause { | |
| let foundCause = causes.find((c) => c.causeCode === claim.causeCode()); | |
| if (!foundCause) { | |
| throw new CannotFindMatchingCauseError(); | |
| } | |
| return foundCause; | |
| } | |
| } |
This materialisation of the peers was done by AI, as well.
Finally, we completely removed the usage of the unnecessary interfaces from AcmeCompanyApi:
| class AcmeCompanyApi implements CompanyApi { | |
| private readonly acmeCausesEndpoint: AcmeCausesEndpoint; | |
| private readonly claimOpeningEndpoint: AcmeClaimOpeningEndpoint; | |
| private readonly authTokenRetriever: AcmeAuthTokenRetriever; | |
| constructor(config: AcmeApiConfig) { | |
| this.acmeCausesEndpoint = new AcmeCausesEndpoint(config); | |
| this.claimOpeningEndpoint = new AcmeClaimOpeningEndpoint(config); | |
| this.authTokenRetriever = new AcmeAuthTokenRetriever(config); | |
| } | |
| async open(claim: Claim): Promise<OpeningResult> { | |
| const token = await this.authTokenRetriever.retrieveToken(); | |
| const causes = await this.acmeCausesEndpoint.getAllFor(claim, token); | |
| const cause = this.findCauseInClaim(causes, claim); | |
| const referenceInCompany = await this.claimOpeningEndpoint.open(claim, cause, token); | |
| return OpeningResult.successful(referenceInCompany, claim); | |
| } | |
| private findCauseInClaim(causes: Cause[], claim: Claim): Cause { | |
| let foundCause = causes.find((c) => c.causeCode === claim.causeCode()); | |
| if (!foundCause) { | |
| throw new CannotFindMatchingCauseError(); | |
| } | |
| return foundCause; | |
| } | |
| } |
and deleted the unused interfaces.
Separating responsibilities led to both production code and tests that were easier to evolve and maintain. We achieved these benefits by introducing composition. However, this leads to a new problem: how should we know if the object graph we compose has the desired behaviour?
To avoid this decrease in predictability, we can complement the existing unit tests with one broad integration test that checks the desired composed behaviour is there.
Summary.
In this post we have shown how an object with too many responsibilities can lead us to unintentionally increase coupling between tests and production code, when we try to make it easier to test-drive. We also showed how separating responsibilities can lead to simpler and more maintainable tests and production code.
The original AcmeCompanyApi was responsible both for coordinating multiple external endpoints to open a claim and for handling and handling all possible errors.
To avoid having to write cumbersome integration tests for the error handling, we had introduced “testing-only” interfaces,
which made the resulting tests easier to write, but more sensitive to structural changes, even when behaviour remained the same.
We decided to separate the two responsibilities by introducing a decorator that took over the error handling logic, and allowed AcmeCompanyApi
to focus exclusively on orchestrating the claim opening process.
This separation made each responsibility explicit in production code and allowed the error-handling logic to be tested in isolation.
As a result, the tests for error handling became simpler and more robust.
They had a much better structure-insensitivity because they were only coupled
to the CompanyApi interface, the entry point to the role of opening a claim in a company.
This decoupling made it possible to materialise the three former peers of AcmeCompanyApi and remove the unnecessary interfaces altogether.
At the same time, by using test doubles to simulate that the CompanyApi raises exceptions, we could still avoid writing cumbersome broad integration tests.
Both production code and tests became easier to evolve and maintain because separating responsibilities made each component’s responsibility explicit and reduced coupling between them. However, notice that these benefits came at the price of having to pay careful attention when composing the object graph, because an incorrect composition could lead to unexpected behaviour. To avoid this problem, we should complement unit tests with at least one integration test that explicitly validates the composed behaviour. Doing this improves the predictability of the tests.
In a future post, we’ll show how AI helped in both the introduction of the decorator to separate responsibilities and the later simplifications made possible by the new design.
Acknowledgements.
I’d like to thank Fran Reyes and Emmanuel Valverde Ramos for giving me feedback about several drafts of this post.
Finally, I’d also like to thank Cottonbro Studio for the photo.
References.
-
Mock roles, not objects, Steve Freeman, Nat Pryce, Tim Mackinnon and Joe Walnes.
-
Materialization: turning a false peer into an internal, Emmanuel Valverde Ramos and Manuel Rivero.
Notes.
[1] Emmanuel Valverde Ramos and I talked about materialisation in the post: Materialization: turning a false peer into an internal