encore icon indicating copy to clipboard operation
encore copied to clipboard

Proposal: support for Dependency Injection

Open eandre opened this issue 2 years ago • 1 comments

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.

eandre avatar May 31 '22 19:05 eandre

Love the general approach, I have a couple of questions and thoughts;

On the server side

  1. 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 the start function return all the service structs, or do you need to prefix the start function with startService and startOtherStruct?

  2. Should the service struct be exported, I think it should actually be private to the service.

  3. 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)

  4. 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)

  5. If you have 100 services; does the start on all 100 get called on the first request, or does start 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

  1. I think I'd prefer, as the default calls could be foobar.Client.MyEndpoint from other packages, rather than having to call foorbar.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)
}
  1. 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)

DomBlack avatar Jun 01 '22 11:06 DomBlack