Entrait 0.4 โ€” loosely coupled application design made easy

Entrait (docs) is a proc-macro for designing loosely coupled applications, enabling the same testing flexibility that users of more orthodox object-oriented languages are used to.

Entrait leverages traits and trait bounds as an alternative to classical object-oriented dependency injection. It allows large-scale loose coupling to be completely erased at runtime.

If this triggers your curiosity, please take a look!

4 Likes

I'm looking for a good solution for DI in Rust and this is getting me interested indeed.

My requirements is that I don't want any implicit-DI (automatic injections with some global containers) as I find it very anti-Rust-philosophy (explicit over implicit). This seems to match here as far as I can tell, so that's great.

I don't have a lot of time RN to look into it, but for some reason I'm getting quite confused looking at the examples and the realworldapp. Nothing unusual - a lot of crates like this confused me before I wrapped my head around what is what. Recently it was a error-stack.

IIUC the idea of all the dependencies being tracked as a single variable implementing bunch of traits seems quite interesting.

However:

#[entrait(Foo)]
fn foo(deps: &impl Bar) -> i32 {
    deps.bar()
}

#[entrait(Bar)]
fn bar(_deps: &impl std::any::Any) -> i32 {
    42
}

let app = Impl::new(());
assert_eq!(42, app.foo());

I'm looking at this example from the docs, and I can't see how is the link between fn foo and fn bar established.

As a bottom-up-learning person, I really need understand what is going under the hood there. The documentation should quite early show me what does this derive macro actually expand into (possibly abbreviated).

All my feedback for now. I'll try to find some time to look at it later. Maybe I just didn't get enough coffee yet.

1 Like

I'm happy that you find my crate interesting!

Usually, when writing and debugging macros and I wonder what exactly the macro is doing, I use an excellent rust-analyzer command: Rust analyzer: Expand macro recursively. Place the cursor over the macro invocation and execute this command, you'll get a new editor pane which shows the expanded code.

The clue with entrait is that it does not only generate traits, it generates implemented traits. This is why we need that Impl::new(()). Impl is a "marker-like" newtype with the meaning that it represents actual, real implementation - as opposed to mocked implementation.

The generated implementation of the trait from #[entrait(Foo)] in your given example becomes something like:

impl<T> Foo for ::entrait::Impl<T>
where
    ::entrait::Impl<T>: Bar,
    T: Sync,
{
    fn foo(&self) -> i32 {
        foo(self) // <------ calls your function
    }
}

I.e. This trait is implemented for Impl<T> when Impl<T> also implements Bar. And that implementation consists of calling the free-standing foo function, that you have written, with self as argument. Then it should be clear, by applying the same thinking, that calling deps.bar() from within that function, when the type of deps is Impl<T> for some T, calls the Bar implementation for Impl<T>, which again actually calls your bar function!

1 Like

Seems useful. I'll try to get it working in Kakoune (kak-lsp) at some point.

Though I still think it's useful to put it explicitly in the documentation.

Oh, I see. That's what I've been missing all this time.

Twists my mind a bit, but I guess it will pass. I'll see if I can give it a try in one my projects to get some DI-based testing done, and report back. Feel free to ping me sometimes if I am MIA, and you have no other users with feedback.

In fact that was in the documentation for an earlier version, but somehow I felt that it was a bit noisy and I restructured a large part of it for version 0.4, describing more things in plain english instead of showing generated code. Maybe that wasn't such a great idea after all. Feedback on documentation is very valuable, becuase it is quite difficult to judge by myself whether it's being too vague or too detailed.

2 Likes

I have some non-trivial projects with a lot of documentation myself, and generally I find it a bit of mission impossible. There is just so many different people, with different backgrounds, different preferences, that it's impossible to make something working for everyone.

And the more documentation you have, the more content is there to look for specific things and so on. I still try to write good docs, but tend to rely on Github Discussions (enable and link to it everywhere) to just encourage people to ask questions, and hopefully build a community that can self-help.

But yeah, personally knowing that this macro expands to exactly what you pasted here for me, makes me grasp things much faster.

BTW. I haven't tried it yet, so possibly prematurely, but I'm wondering - if there's a way to group multiple methods in one trait. Something tells me that the number of these traits is going to get very large fast. I'm more used to interface-based DI, where I would just inject interface with a group of methods.

1 Like

Single-method traits is a central design decision in entrait. It was the only way I was able to keep required user-supplied boilerplate code to an absolute minimum, and avoid inventing a whole new DSL-like thing, e.g. entrait! { invent some Rust-like language here }. I didn't want a library that looked like that. You are right there will be a lot of traits. I haven't had time to measure the impact on compile times.

But there is actually support for manually written traits, but only as "leaf dependencies".

The ArticleRoutes<D> example is just a way I could make this work with a group of axum routes without repeating the generics for each handler and route builder. I.e. just create a struct with bound generics (early bound generic params?) and use static methods.

edit: You only need to write axum routes that way if you actually need to test the routes by mocking out the direct dependencies behind. If you don't need that, you can just hard-code Extension<Impl<MyApp>>.

That might work for things I was thinking about.

I dislike DSL-like macros myself so I support that design goal.

BTW. Isn't "leaf dependencies" constrain possibly removable? I could imagine some kind of extra annotations that would make the #[entrait] on traits do all sorts of things. Not sure if a good idea or if really needed, but seems like this would avoid macro-DSL and make this approach have no limits.

Let's say you write your own trait:

trait Foobar {
    fn foo(&self);
    fn bar(&self);
}

Now you supply the implementation of that (this has a Baz dependency):

impl<T> Foobar for Impl<T> where Self: Baz {
    fn foo(&self) {
        // some business logic...
        self.baz();
    }

    fn bar(&self) {
        self.baz();
    }
}

Now you'd like to test this implementation by supplying a mock implementation of Baz. But that's not possible because it's given that what supplies the implementation of Baz is Self, which is Impl<T>, which is by definition not mocked.

The only way I was able to come up with a design that allows injecting mocks, was the free-standing global function design - completely decoupled from any kind of impl blocks. The impl blocks injecting self into the global function is the key.

So you could rewrite your Foobar impl again and inject self into global functions that you also write by hand. But now you have a lot of manual boilerplate, and the only win is that you have one trait with many methods instead of several one-method traits.

A half-solution could be something like this:

#[entrait(call_global_functions = true, deps=[Baz])]
trait Foobar {
    fn foo(&self);
    fn bar(&self);
}

fn foo(deps: &Baz) {
    // business logic
   deps.baz()
}

fn bar(deps: &Baz) {
    deps.baz()
}

And autogenerate the Impl<T> block by injecting self into the global functions. It would certainly work, the only thing that's a bit problematic to me is that the proc-macro isn't as "self-contained" anymore. I.e. you could have a compile error because of something that is not local to the proc-macro, because you either forgot to define one of those functions or misspelled one of them, mismatched arguments, etc.

One could possibly do something like this, but that requires syntax modification of input code, which is something I wanted to stay away from:

#[entrait]
trait Foobar {
    fn foo(deps: &Baz) {
        deps.baz();
    }

    fn bar(deps: &Baz) {
        deps.baz();
    }
}

I thought about it some more, and I have an idea. I don't like changing the meaning of what the user wrote, so I want the input to the macro to be just plain, understandable Rust. I want the macro to be an attribute so that the attributed item has to be valid code on its own, and I don't want to change the syntax of that code as it passes through the macro.

So, a way to group standalone functions together so the macro can see all of them is to use mod {}:

#[entrait(FooBar)]
mod foo_bar {
    fn foo(deps: &impl super::Baz) {
        deps.baz();
    }
    fn bar(deps: &impl super::Qux) {
        deps.qux();
    }

    #[cfg(test)]
    mod tests {
        // unit test foo and bar functions...
    }
}

All the fn items directly inside that module get turned into methods on the generated trait. And it also demonstrates how you can have different deps for each method: Those bounds can just be accumulated in the generated where clause for Impl<T>.

What do you think?

edit: There's also another way to group items together without introducing a module, using instead an anonymous namespace. I don't know exactly what this is called, but we can write const _: () = { .... items here... };. Items defined inside are inaccessible to the outside, so unit tests would need to live inside this anonymous block. The downside of this approach I think is that const syntax, it looks more like a hack to me than anything else.

1 Like

It appears to be Const wildcard, RFC 2526

I've spent like 30 minutes yesterday thinking about it, and somehow mod didn't cross my mind, and I think it is a bit tricky, and maybe too clever (confusing) but I think it will work just fine giving the constraints. :smiley:

Yes, there are several things to think about. First, the macro invocation will be a lot heavier and process a lot more tokens in one go. This could lead to rust-analyzer performance issues, but I don't know. I try to keep my macros as efficient as possible!

Second, I think the generated trait needs to be placed within the module. It's impossible to put it outside because of the changing namespace when entering a new module, all of the generated code has to share the same namespace. This means that the foo_bar name in mod foo_bar suddenly becomes an awkward extra path segment when you want to import the trait from other modules. Maybe it could be solved with a pub use foo_bar::*. But this extra module still needs to be named, and it appears quite "artificial" and hacky.

My two cents for this: I personally use <details> sections for that kind of thing, especially for generated code:

/// ### Generated code
///
/// <details>
///
/// ```rust
/// โ€ฆ
/// ```
///
/// </details>
  • (you could also slap a <summary>โ€ฆ</summary> to override that translated "Details" name and instead provide your own text, but there is a docs.rs bugs then which requires tweaking the CSS to prevent the drop-down menu from being hidden).
1 Like

That's quite nifty! I had no idea you could do that. Thanks for the tip, I'll see if this could fit into my crate document, which is already getting quite long.

1 Like

Do you have any example at hand how it looks rendered?

1 Like

Most of my crates use it:

Click to see ๐Ÿ™ƒ
  • you can see the summary bug I was talking about in one of the first iterations:

    ::with_locals

    but the two previous crates feature a workaround that dodges the issue :slight_smile:

More recently I've even committed to using some extra CSS to make such snippets more "eye-cacthing", since I did get some feedback with lending-iterator that some of these snippets were easy to miss when skimming over the docs:

1 Like

@dpc I think you convinced me that multi-method traits can be a good idea. I like it the more I think about it. So I have a basic working implementation of "module-mode" in a branch. This is the first working test: entrait/test_simple.rs at entrait-module ยท audunhalland/entrait ยท GitHub

What's interesting about "module-mode" is that in the entrait docs (and in the first blog post that explored the entrait design) I have criticized the way that OOP-dependency-injection uses classes-as-code-modules and that the one-to-many (dependency <-> function) relationships makes it harder to write unit tests because function signatures hide away the actual dependencies used in each tested function (i.e. dependencies are defined at module-level, not at the function level. They are passed in as constructor arguments to the instantiated module/class). So even if we now have multiple functions per dependency, it's not as bad as in OOP, because here each function still declares its immediate dependencies. I think I like that.

1 Like