DEV Community

Timo Schinkel
Timo Schinkel

Posted on

Friday Thoughts on abstraction and not wanting to know

Twice this week I found myself in a discussion about how to abstract a multicast-like behavior. Both times the discussion revolved around "what part of the code should have what knowledge"?

The first situation was about retrieving information. To abstract the retrieval we introduced an interface - let's say IRetrievalService. For reasons beyond the scope of this text some of this information can still be in a legacy system. When we built this application we decided to ignore the legacy system as it had not been updated in years. But analysis showed we would lose revenue, and so the only solution was to also query the legacy service. One colleague introduced the switch logic in the consumer of the service, in our case a controller:

try {
    const information = await this.retrievalService.retrieve()
        .catch(async (error) => {
            if (error instanceof NotFoundError) {
                return this.legacyRetrievalService.retrieve();
            }
            throw error;
        });
} catch (error) {
    // handle error scenario
}
Enter fullscreen mode Exit fullscreen mode

Another colleague suggested solving this "within" the interface; by introducing a fallback implementation that calls both the current and - if needed - the legacy system:

export class FallbackRetrievalService implements IRetrievalService
{
    constructor(
        private readonly retrievalService: IRetrievalService,
        private readonly fallbackRetrievalService: IRetrievalService,
    ) {
    }

    public async retrieve(): Promise<unknown> {
        return this.retrievalService.retrieve()
            .catch(async (error) => {
                if (error instanceof NotFoundError) {
                    return this.fallbackRetrievalService.retrieve();
                } 
                throw error;
            });
    }
}
Enter fullscreen mode Exit fullscreen mode

Both approaches do exactly the same. The difference is that for the first every part of the codebase that calls the IRetrievalService needs to have knowledge about when and how to fall back to the legacy service. The latter approach abstracts that knowledge away with the result that none of the parts of the codebase that call the IRetrievalService needs to know about the legacy service. In fact only two parts of the code need to have some knowledge about this behavior; the actual FallbackRetrievalService and the code that handles dependency injection/initialization.

I think that for every abstraction that you write you need to ask yourself the question: Who needs to know what? I have seen too many occurrences where an interface required a table name as parameter - should the code calling the interface have the knowledge that the data is stored in a database? -, and too many occurrences where an interface could throw a database exception or an HTTP exception - again; should the code calling the interface have the knowledge that that is coming from a database or API? No. At least, not in my opinion.

By consequently asking yourself the question "where should this knowledge be?" you can make your code much more flexible and concise. If a year from now we decide to remove the legacy service, all that is to be done is change the dependency injection/initialization and remove a few files. None of the calling code needs to be changed. It also helps you with testing; you want to be sure that the fallback scenario is working as expected. With a FallbackRetrievalService you only need to test that class, and you don't need to test all possible variations at every location where it is called. As a bonus it is much easier to ensure the fallback (or multicast) is applied everywhere.

Top comments (0)