Composing services #83
Replies: 4 comments 4 replies
-
@iamcalledrob Struggling with the same kind of decision as you are describing, did you end up deciding what to do? |
Beta Was this translation helpful? Give feedback.
-
I ended up organizing "glue" functions and structs at the domain level, and it's been working well. Behaviour of the glue logic can then be tested using mocked dependencies. Where possible I keep this glue to be a single function rather than a struct. The line of thinking here is that this is "domain logic" that has a single, canonical implementation -- just like the domain types. Here's a simple real-world example of a glue function that connects two services together: func AddNewUsersToList(userService foo.UserService, listService foo.ListService) {
userService.OnNewUser(func(user *foo.User) {
_ = listService.AddSubscriber(user.Email)
})
} Here's a minimal example of a glue function that composes search results from two services, showing how common logic can be used regardless of the underlying services. func SearchResults(ctx context.Context, query string, userService foo.UserService, photoService foo.PhotoService) (results []any, err error) {
if len(query) < 3 {
err = errors.New("query too short")
return
}
users, _ := userService.FindUsers(ctx, foo.UserFilter{ NameContains: query })
photos, _ := photoService.FindPhotos(ctx, foo.PhotoFilter { HasTag: query })
for _, u := range users {
results = append(results, u)
}
for _, p := range photos {
results = append(results, p)
}
} Perhaps userService is a Lastly, for more complicated functionality where a common set of dependencies is needed across several related functions, I'm using structs to hold those dependencies For example (this is not real code, but should show the design): type RegistrationService struct {
userService foo.UserService
otpService foo.OtpService
mailService foo.MailService
}
func (r *RegistrationService) SendOTP(ctx context.Context, email string) error {
_, err := r.userService.GetUserByEmail(email)
if err != nil && !errors.Is(err, foo.ErrNotFound) {
return fmt.Errorf("checking for existence of user by email: %w", err)
}
if err == nil {
return fmt.Errorf("user already exists", err)
}
// Function that does some work then in-turn calls in to r.otpService
// If this work wasn't re-used, CreateAndInsertOTP could be inlined here.
var otp [6]byte
otp, err = CreateAndInsertOTP(ctx, r.otpService, email)
if err != nil {
return fmt.Errorf("creating and inserting otp: %w", err)
}
err = r.mailService.SendMail(ctx, email, "Your otp is " + string(otp))
if err != nil {
return fmt.Errorf("sending mail: %w", err)
}
return nil
}
func (r *RegistrationService) Register(ctx context.Context, otp [6]byte, email string, name string) (*foo.User, error) {
err := r.otpService.ConsumeOTP(ctx, otp, email)
if err != nil {
return nil, fmt.Errorf("consuming otp: %w", err)
}
user := &foo.User{Name: name, Email: email}
err = r.userService.InsertUser(ctx, user)
if err != nil {
return nil, fmt.Errorf("inserting user: %w", err)
}
err = r.mailService.SendMail(ctx, email, "Thanks for registering!")
if err != nil {
return nil, fmt.Errorf("sending mail: %w", err)
}
return user, nil
}
// CreateAndInsertOTP securely generates an OTP for the provided email and inserts it using the OtpService
func CreateAndInsertOTP(ctx context.Context, otpService foo.OtpService, email string) ([6]byte, error) {
otp, err := cryptopkg.GenerateSomethingSecurely(6)
if err != nil {
return nil, fmt.Errorf("generating secure bytes: %w", err)
}
err = otpService.InsertOTP(ctx, otp, email)
if err != nil {
return nil, fmt.Errorf("inserting otp: %w", err)
}
return otp, nil
} This "RegistrationService" can then be tested using mocked dependencies, e.g. to ensure that it won't let a user register twice. There are a few ergonomic trade-offs with this approach which I don't have a great solution to. Mainly, it breaks the pattern that In the real world, I haven't found a strong need to mock this logic. Hope this is helpful! |
Beta Was this translation helpful? Give feedback.
-
Hey! I think your question is very similar to this already answered one: Or am I wrong? |
Beta Was this translation helpful? Give feedback.
-
@iamcalledrob I think I fully agree with your findings here. What I dislike about the "extra-package" approach you mentioned at the beggining, is that domain logic/definitions are now spread over two packages: I'd prefer putting all domain related things into On the other hand, having everything as plain functions can be inconvenient as I cannot group data into structs. I also tend to have two interfaces per "domain entity", like package domain
type FooService interface {
FooRepository
// Some extra methods here
}
type FooRepository interface {
GetFoo(id string) (*Foo, error)
// ...
} Another alternative to I'd like to hear your thoughts about this. |
Beta Was this translation helpful? Give feedback.
-
Hi there. I've been using the "wtfdial" approach for a while, and have really been enjoying it for how well it encourages layering and discourages spaghetti.
One area that's less obvious is how to elegantly compose services together.
For example, imagine we want to add a NewsFeedStoryService:
An implementation of which "composes" together a bunch of underlying services:
I see two ways this could be implemented:
1. As "domain logic", at the module root.
This glue code is canonical logic for how to combine posts and photos into a news feed. The interface would still be needed in order to be able to substitute the real version for a mock version when testing a higher level in the stack, e.g. the http layer.
The main trade-offs I see:
sqlite.PostsService
plusinmem.PhotosService
rather than mocks. These tests would have to be pushed into a different package which moves the test code much further away from the implementation.2. As an additional "layer" in a separate package
This glue code could also be considered a "layer", inside a different package. Theoretically you could have multiple totally different implementations of the NewsFeedService logic if needed, e.g. for multivariate testing -- but in our scenario we don't need that.
The trade-off here is that this "layer" doesn't obviously fit into the existing structure, e.g. "http", "sqlite", "inmem" etc. It's initially written as a default, canonical implementation.
newsfeed
andnewsfeed.Service{}
, but this feels like arbitrary package creation, and doesn't fit into the layered approach.composed
andcomposed.NewsFeedService{}
, but this "composed" glue layer is totally arbitrary -- and do you include totally unrelated glue logic there too as you compose more services, e.g.composed.WeatherService{}
etc...?I'm curious if anyone has solved this well, and if there are other more subtle trade-offs?
Beta Was this translation helpful? Give feedback.
All reactions