Dependency injection with crystal macros

I just wrote up a blog post on how I implemented a dependency injection framework using Crystal macros:

You can skip straight to the code:

I think it differs a little from other projects that I’ve seen in that I’m following the model of Dagger (which I’ve used for Android development) and so it supports arbitrarily nested scopes to control where certain classes can be accessed.

Doing this with macros is neat because it should mean that there’s no runtime overhead to looking up the existing objects.

4 Likes

This was a good read! I know there aren’t many DI implementations in Crystal, so was interesting to see how someone else went about implementing it. I have a few thoughts/questions:

The next trick is to use the unsafe .allocate method to grab some uninitialised memory where we can put our object. We then just call the right initialize method (as defined by method ) which will set the instance variables. That’s just a matter of generating a method call from the information in method :

Is there a reason you have to use .allocate? If the Inject annotation is always applied to the initialize method, it should be safe to just do {{ @type }}.new(...) and let Crystal itself find that overload?

Since Crystal macros don’t have methods, every time we want to access the specially-named getter, we have to duplicate that snippet.

I feel you :sob:, would love to get more feedback on/move forward with RFC 0018: Macro Methods by Blacksmoke16 · Pull Request #18 · crystal-lang/rfcs · GitHub as a way to DRY things up a bit. I found /[^\w]/ to be a bit more robust as well.

For subscopes that correspond to a particular action (like an HTTP request) you need to be able to put values into the dependency graph.

This feels odd to me (assuming it’s not just for example purposes). Like would it not be better to not have the server context be in the DI container, but passed to a related service as an argument? E.g. def process(context : HTTP::Server::Context)? The scoping concept is neat but I think I’d have to see some more real-world use cases to fully understand its benefit.

1 Like

You’re right about .allocate, I totally could have just called .new. The way it’s implemented currently means you can have a method that’s not called initialize do the initialisation, but that doesn’t play nice with the uninitialised variable checker, so I should just require it be initialize and call new.

It’s hard to give a good example to demonstrate why scopes are useful, as for small applications they mostly just add complexity. The Dagger documentation on subcomponents might be interesting, that’s where I’ve lifted the idea from.

The advantages get more noticeable the deeper your dependency graph grows. Let’s say I added authentication to this HTTP server and now each request will come from a particular user. Without a scope, I’d have to plumb this value through the entire call stack to where it’s needed. With a scope, I can just make a User available in the dependency graph for all requests, and anyone that needs it can just inject it, the layers in-between don’t have to be changed.

I guess an HTTP server in Crystal is a poor example, since what you’d do for request-specific information would be to patch HTTP::Server::Context to hold the information I needed and forget about any dep injection. In languages without patching and macros you’d be subclassing and writing this boilerplate by hand, which is terrible and why dependency injection is used.

For example in an Android app if you have some object that needs to be shared within an activity (ie a screen of the app) you’d probably just hold a reference to it in your subclass of Activity, then anyone can grab the current activity, cast it, and read the field. Dagger/Hilt just auto-generate this boilerplate.

Could set it up to just call whatever class method it’s applied to. That would handle custom “factory”-like constructors as well. E.g. .from_dsn or whatever. If it’s applied to initialize, then fallback to .new, or possibly even if there is no Inject annotation at all.

Hmm yea, maybe it just stems from a diff architecture for a different primary use case?

The way I’m more accustomed to handling this is leveraging the mutable state of the services. E.g. at some point in the request/response life-cycle, after performing the authentication check, you set the User instance to some service. From here other downstream services can inject your “UserStore” service or whatever and access the current user if one is defined. From here you could pass it around as an argument, to additional services if needed. This way the DI container consists just of actual dependencies and not also runtime values.

This is essentially what I did for integrating the Athena DI component. Each request has its own container scoped to the fiber its running in, so it’s free to mutate things as needed without leaking, or having to reset state, between requests.

Each request has its own container scoped to the fiber its running in, so it’s free to mutate things as needed without leaking, or having to reset state, between requests.

That makes sense, I guess the difference here is that you can inject stuff from the global scope as well as from the local scope.

Could set it up to just call whatever class method it’s applied to.

Ooh that’s a nice idea, since the .new method will inherit the annotations from the initialize method so it’ll just work.

1 Like