Quantcast
Channel: User Filip Milovanović - Software Engineering Stack Exchange
Viewing all articles
Browse latest Browse all 164

Answer by Filip Milovanović for Repository and Service Interfaces in an Accouting Software in Go with Uncle Bob's Clean Architecture

$
0
0

My main confusion is about where to define interfaces for repositories and services.

Generally speaking, in component design, there are two categories of interfaces - (1) provided interfaces (those that the component provides for its clients), and (2) required interfaces (those that the component requires of its plugins/collaborators).

When it comes to layering, you place interfaces (or abstract classes, or facade classes) that play the role (1)(provided interface) in the same layer, together with the implementation. The goal here is to expose a relatively narrow, well defined "surface area" for the clients to use, minimizing the amount of things they can be coupled to, and hiding the details of how it is all implemented behind the scenes (so that it's easier to vary them).

When an interface is in the role (2)(required interface), you still think of the component that requires it as being the "owner" of the interface (as opposed to the implementing component), and you define the methods on the interface in terms of the specific needs of the owning component, rather than in terms of the general capabilities of the class that's going to implement it. The required interface and the owning component form a sort of a cohesive unit that lives in the same layer. If this seems strange to you - recall that this same idea is behind the Strategy Pattern. The implementation of the interface then provides some specific service to the component, and may be defined in a different layer.

In terms of Clean Architecture, if the implementation is defined in an outer layer, then this is a form of dependency inversion - the dependency arrow (in this case, from the implementation to the interface) originates in an outer layer, and points towards an inner layer, and is conditioned on the needs of the component that owns the interface (because, remember, that's what guided the design of the interface), while the control flow goes in the opposite direction. The key idea here is to find an interface design that captures the needs of the owning component (see below) in a way that makes the interface more stable in the face of changes compared to both the owning component and an implementation. You would normally use dependency injection (constructor injection, most commonly) to inject some chosen implementation into the component at the composition root.

Interfaces to repositories and services are of this second kind, generally speaking. You declare them with respect to the owning Use Case Interactor, and design them to have methods that are a reflection of the needs of the use case, and then you implement those interfaces in an outer layer. I.e., instead of having an interface to a repository define general-purpose CRUD operations, you define methods that are a bit more high level, e.g. something like GetAdminUsers(accessTier), and you do the CRUD-y stuff behind the scenes, in the implementation of the method. The tricky part here is to come up with a stable set of methods that still allows you to express the logic that the use case needs to implement.1 The takeaway here is that merely placing an interface between two classes doesn't magically decouple them - you need to come up with the right set of methods (right abstraction).

There are circumstances where you might want to take a different route, though. E.g., if your domain involves users constructing arbitrary queries, or there is a lot of complexity in the kinds of queries you need to do - then you might instead prefer using some kind of (possibly domain specific) query language, and part of the domain logic would revolve around the rules governing this query language.

Clean architecture - details

The diagram above comes from Martin himself (and IIRC you'll find a similar graphic in the book). The Data Access Interface is essentially a gateway interface to what you call a repository - note that the interface is on the same side of the architectural boundary (the double line) as the use case, but its implementation isn't. Also, don't take this diagram as gospel - the exact elements shown aren't as important as are the relationships between them. The elements shown are meant to represent architectural elements, rather than be a literal blueprint, and your specific application might end up looking slightly different. For example, Input Data may be an explicit struct of some sort, or it may simply represent the parameter list on a method on the Input Boundary interface. Or, if there's a need for it, the Use Case Interactor may actually delegate part of the logic to one or two other classes not shown on the diagram.

Now, how exactly you distribute various responsibilities across different classes is up to you (and your team). You can have one repository (or service) implement several different interfaces, possibly coming from different use cases. You can have one use case require two different interfaces (two dependencies playing different roles), that are initially both implemented by the same class (this seems odd at first, but it allows you to replace one of the dependencies independently). You can have a distinct repository implementation per use case, where all of those repositories may or may not use the same underlying lower level object/library internally. They may or may not all interact with the same database. Or you can mix and match to suit the needs of the project.

If you have a repo or a service that represents a concern that cross-cuts across use cases, it's fine to share the same implementation across all of them, assuming there is no compelling reason not to share, and assuming the implementation does not depend on the specifics of any particular use case.You can think of such an interface as conceptually being owned by the layer itself, or perhaps by some other logical grouping of components within the layer. Note also that "sharing an implementation" does not necessarily mean that the same instance of the object is given to all the use cases, though it could be (again, you'll have to decide what to do here given your problem domain, the constraints you face, etc).

As a side note, while CA puts a decent amount of emphasis on layering, it doesn't actually require you to physically divide your project structure into said layers. IMO, it's disadvantageous to decide on that at the very start of the project, and then get locked into it. The layers are primarily a logical separation, but note that you are also expected to separate concerns within layers - yielding granular components - and then decide how you want to package them up (by layer, across layers by feature (vertical slices), or some other way).


1 Note that you're not expected to get the design perfectly right from the very beginning - come up with something based on what you know and understand about the domain at that point in time, try to make (and embed in code) as few assumptions as possible (follow the YAGNI & KISS principles), and as you learn more about the domain, adjust the design from time to time, when you feel that there are parts of the design that are starting to get in your way (choices you made that turned out less than optimal), always striving to restructure things so that the codebase becomes more comfortable to work with, given what you understand about the problem domain at that time, rather than religiously following generalized "best practices" or overcommitting to earlier decisions for "consistency" - these lead to overengineering, spaghetti code, and you and your team spending more and more of your time trying to keep together the contraption that the application has become.


Viewing all articles
Browse latest Browse all 164

Trending Articles