Practical guide
The single-implementation interface that increases coupling
A conventional UserServiceInterface forces different consumers to depend on the same broad contract and shows when the interface belongs to the consumer.
A users package contains a service and an interface in front of it:
package users
type UserServiceInterface interface {
Create(context.Context, CreateUser) (User, error)
Find(context.Context, UserID) (User, error)
Rename(context.Context, UserID, string) error
Delete(context.Context, UserID) error
List(context.Context) ([]User, error)
Export(context.Context, io.Writer) error
}
type UserService struct {
store Store
}
func NewUserService(store Store) UserServiceInterface {
return &UserService{store: store}
}There is only one implementation: UserService. The interface was added because "services must have an interface" and because perhaps one day there will be another implementation.
On paper it looks like decoupling. The opposite happens with consumers.
The welcome package only needs to find a user:
type Sender struct {
users users.UserServiceInterface
}The admin package needs to rename and delete users. A job needs Export. All three depend on the same surface even though their conversations are different.
Each new method changes the shared contract. Every fake must grow. Each consumer can start using operations that never belonged to its responsibility. The interface hides the concrete type but exposes a larger relationship than necessary.
An interface does not reduce dependencies by existing#
Coupling is not measured by counting whether a signature contains an interface or a struct. It is observed by asking what promises each consumer knows.
welcome conceptually depends on a single conversation:
Find(UserID) → User | UserNotFoundBut UserServiceInterface gives it six. Although welcome does not call Delete, its constructor claims that it needs an object capable of deleting, exporting, and listing users.
The difference becomes visible when testing:
type fakeUsers struct{}
func (fakeUsers) Find(context.Context, users.UserID) (users.User, error) {
return users.User{Email: "reader@example.com"}, nil
}
// Satisfying UserServiceInterface also requires implementing
// Create, Rename, Delete, List, and Export.The fake is not incomplete regarding welcome. It is incomplete compared to an interface designed from the provider.
First option: use the concrete type#
If a consumer does not need to replace the service and the specific package expresses the conversation well, there is no need to anticipate another layer:
type Sender struct {
users *users.UserService
}
func NewSender(subject *users.UserService) *Sender {
return &Sender{users: subject}
}This does not remove the API. UserService.Find is still a cross-package API: it has a message, vocabulary, results and guarantees. An API does not need to take the interface form of the language.
The concrete type reveals a real dependency instead of pretending that consumers need a substitution they never requested.
If Sender tests can use a small UserService with an in-memory store, that composition tests the actual conversation without creating an additional contract. Introducing an interface just to get a mock is not automatically more modular.
Second option: the interface belongs to the consumer#
Now suppose welcome needs to handle absent and found users without building the entire module. Or perhaps it can work with different providers. There is now a concrete need for substitution.
The interface can be born where that need exists:
package welcome
type Users interface {
Find(context.Context, users.UserID) (users.User, error)
}
type Sender struct {
users Users
}
func NewSender(subject Users) *Sender {
return &Sender{users: subject}
}*users.UserService satisfies welcome.Users without declaring it. The fake too:
type stubUsers struct {
user users.User
err error
}
func (s stubUsers) Find(
context.Context,
users.UserID,
) (users.User, error) {
return s.user, s.err
}Now the interface describes exactly what Sender needs. admin can use the concrete type or define another interface with Rename and Delete. A change to Export does not affect any of them.
We haven't divided a large interface by aesthetics. We have recognized that there are different conversations.
The provider retains its own API#
Moving the interface to the consumer does not mean that the consumer owns User, UserID, or the search rules. These elements still belong to the vocabulary offered by users.
There are two related boundaries:
usersdecides what it means to find a user and whatUserServiceguarantees;welcomedecides how much of that capacity it needs to send welcomes.
API-DD repeats on both sides. A module offers one API, and by consuming another, it can declare a smaller need. This is recursion applied to a real relationship, not an interface added to each package for symmetry.
When an interface in the provider does make sense#
A provider-owned interface may be correct when substitution is part of the capability it offers. For example, a package may define a Codec so its consumers can choose among compatible implementations, or publish a deliberately stable plugin contract.
The difference is who needs to recognize the implementation family.
Provider interface:
the provider offers several implementations as part of its API.
Consumer interface:
the consumer expresses the minimum capability it needs to receive.If no one needs to choose, replace, or implement the contract, the interface may be a premature abstraction.
The name also gives away the problem#
UserServiceInterface repeats two technical categories. It does not explain a capability that UserService does not already express. Names like Users, UserFinder, or WelcomeRecipients only improve the design when they correspond to a real conversation; renaming the same large interface does not make it small.
The test consists of reading the constructor:
func NewSender(users Users) *SenderCan a person understand which part of users is needed by Sender? Could it be replaced without knowing operations that Sender never uses? If not, the boundary is still drawn from the provider.
The recursion foundation lets us view each module as both provider and consumer. The visibility foundation reminds us that every visible method becomes another promise.
An interface decouples when it represents a consumer's need; created by convention, it only adds another name to the same coupling.