encore
encore copied to clipboard
Proposal: support for Dependency Injection
We've been looking at supporting Dependency Injection in a better way as that's been a highly requested feature by many of you. Here's a high-level proposal of how we see it working. Any and all feedback at this point is extremely valuable!
Background
Dependency Injection means being able to define struct types that accept dependencies as fields, and filling in those fields at runtime with specific implementations (such as database connections, clients for talking with internal or external services, etc). This is useful for testing and to be able to set up stateful objects in a way that doesn't require global variables. A typical use case is:
type MyService struct {
db *sql.DB
component *MyComponent
seenObjects map[string]*MyObject
// ...
}
func (s *MyService) MyEndpoint() {
// ...
}
func main() {
// ....
svc := &MyService{...}
// ... use the fully constructed svc object
}
The problem with this sort of pattern in Encore is that service APIs must be defined as package-level functions, which prohibits this style of programming as they cannot be defined as methods on a type. This proposal is about allowing that, in a way that is both natural and sticks with Encore's philosophy.
Proposed Design
There are two parts to this proposal: defining services, and calling endpoints on those services.
Defining Services
We propose adding the ability to define endpoints as methods on a struct type, like so:
type Service struct { /* ... */ }
//encore:api public
func (s *Service) MyEndpoint(ctx context.Context, p *MyParams) (*MyResponse, error) {
// ... same as before ...
}
In order to be able to populate the Service's fields with the necessary dependencies,
we propose adding the ability to define a start
function that returns a fully constructed Service object:
func start(ctx context.Context) (*Service, error) {
// Service bootstrapping code goes here...
}
Encore will then orchestrate to call the start
function before the service receives its first request.
The given ctx
will be active for the duration of the service, and will be cancelled when the service
is shutting down.
Calling Services
Once we have syntax for defining services with dependencies, we encounter a related problem: calling those services' endpoints. Previously we had a simple way: import the package and call the package-level function. This doesn't work very well with dependency injection, as there no longer is a package-level function to call.
Since it's quite likely that if you want to use dependency injection for one service you're likely to want to use it elsewhere,
we propose having Encore generate a Client
interface for all services that can be used by other services to call it.
In the example above, Encore would generate a file encore.gen.go
inside each service (that lives inside your app folder) containing:
// Code generated by encore. DO NOT EDIT.
package foobar
// NewClient returns a Client that calls the foobar service.
func NewClient() Client {
// The implementation is elided and generated at compile time by Encore.
return nil
}
// Client is a client for calling the foobar service.
type Client interface {
MyEndpoint(ctx context.Context, p *MyParams) (*MyResponse, error)
}
Other services can then call foobar.NewClient()
to get a client and then invoke methods on that.
Encore would automatically maintain this file over time as changes were made to the service.
When Encore compiles the app it would regenerate this file but fill in the NewClient
implementation,
so there is no need to commit the encore.gen.go
file to source control. It only exists to clarify what the types
look like to developers, and to help IDEs and linters understand that this code actually exists.
This approach also lends itself naturally to testing and mocking by providing an always-up-to-date interface for the service's API.
Note that the old way of using package-level functions will continue to be supported as before; no code would break. One open question is whether or not Encore should generate Client
interfaces for services that don't use the new dependency injection support.
Love the general approach, I have a couple of questions and thoughts;
On the server side
-
Will encore only support one "service struct" per package, or could you have multiple? 1a) If one would it be enforced to be named the same as the package - for consistency / expectations - or would a default name of
apis
be used for the struct? 1b) If multiple, does thestart
function return all the service structs, or do you need to prefix the start function withstartService
andstartOtherStruct
? -
Should the service struct be exported, I think it should actually be private to the service.
-
Will the use of spawning go-routines in the start method be discouraged? In the serverless architecture being aimed for this
start
could result in heavy objects being initialised per request if the server is shutting down constantly. (i.e. zookeeper like behaviour) -
If the
start
method returns an error, what happens to the triggering request, can be it retried? On initial build/deployment of a new version - all the start methods would need to be tested and confirmed to actually work before completing the deployment - otherwise Encore should consider the new version to fail the build/tests and refuse to roll it out; less the app ends up in a deployed to product state where it cannot serve any requests. (Effectively a canary deploy to the target environment to verify the services start without error before a full rollout) -
If you have 100 services; does the
start
on all 100 get called on the first request, or doesstart
only get triggered when that specific service is needed? (This could add some nasty startup time to hops in deep graphs)
On the client-side
- I think I'd prefer, as the default calls could be
foobar.Client.MyEndpoint
from other packages, rather than having to callfoorbar.NewClient()
every time you want to access it;
// Code generated by encore. DO NOT EDIT.
package foobar
// Client will be initiated at runtime
var Client ServiceClient
// Client is a client for calling the foobar service.
type ServiceClient interface {
MyEndpoint(ctx context.Context, p *MyParams) (*MyResponse, error)
}
- An idea on the client could also be; rather than creating a Client interface - you could generate the client functions as non-receiver methods, and then have those calls routed to the the methods on the structs. This approach would also ease service migration to and from the DI approach - without impacting the other services that will call that service.
// Code generated by encore. DO NOT EDIT.
package foobar
func MyEndpoint(ctx context.Context, p *MyParams) (*MyResponse, error)