Skip to content

Commit 8301431

Browse files
committed
added note abput dependency injection library
1 parent 0e79b59 commit 8301431

File tree

1 file changed

+377
-0
lines changed

1 file changed

+377
-0
lines changed

2024_12_09_dependency-injection.md

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
---
2+
title: Relative simple and small type-driven dependency injection
3+
---
4+
# First, why is type-based injection better than name-based injection?
5+
6+
7+
Find time to modernize dependency injection in some services. The previous version involved simply passing the context object with fields for services.
8+
9+
```
10+
trait AppContext {
11+
def service1(): Service1
12+
def service2(): Service2
13+
….
14+
}
15+
```
16+
17+
It has worked well, except for a few problems:
18+
- tests, where we need to create context objects with all fields, even if we need only one.
19+
- modularization: When we want to move part of functionality to the shared library, we should also create a library context, and our context should extend the library context.
20+
- one AppContext gives us a ‘dependency loop’: nearly all services depend on AppContext, which depends on all services. So, the recompilation of AppContext causes the recompilation of all services.
21+
22+
However, for relatively small applications, it is possible to live with.
23+
24+
25+
If we switch to type-driven context resolving (i.e., use AppContext[Service1] instead of appContext.service1 ), we will solve the modularization problem.
26+
27+
The first look was at the approach described by @odersky in https://old.reddit.com/r/scala/comments/1eksdo2/automatic_dependency_injection_in_pure_scala/.
28+
(code: https://github.com/scala/scala3/blob/main/tests/run/Providers.scala )
29+
30+
Unfortunately, the proposed technique is not directly applicable to our cases. The main reason is that machinery with math types works only when all types in the tuple are distinct. Therefore, this means all components participating in dependency injection should not be traits. (because if we have two traits (A, B), we can't prove that A and B in general are distinct; therefore, Index[(A, B)] will not resolved. The limitation not to use traits as components is too strict for practical usage. Two other problems (absence of chaining of providers and absence of caching) are fixable and demonstrate a usual gap between 'proof of concept’ and real stuff.
31+
32+
We will use approaches very close to what sideEffffECt describe in https://www.reddit.com/r/scala/comments/1eqqng2/the_simplest_dependency_injection_pure_scala_no/ with few additional steps.
33+
34+
35+
# Basic
36+
37+
We will think that component `C` is provided if we can find AppProvicer[C]]
38+
39+
```
40+
trait AppContextProvider[T] {
41+
def get: T
42+
}
43+
44+
```
45+
46+
```
47+
object AppContext {
48+
49+
50+
def apply[T](using AppContextProvider[T]): T =
51+
summon[AppContextProvider[T]].get
52+
53+
….
54+
55+
}
56+
```
57+
58+
59+
If we have an implicit instance of the object, we think it's provided:
60+
61+
62+
```
63+
object AppContextProvider extends AppContextProviderLowLevel {
64+
65+
66+
given implicitInstanceProvider[T](using T): AppContextProvider[T] with {
67+
def get: T = summon[T]
68+
}
69+
70+
71+
}
72+
```
73+
74+
Also, the usual practice for components is to define its implicit provider in the companion object.
75+
76+
77+
Example of UserSubscription looks like:
78+
79+
```
80+
class UserSubscription(using AppContextProvider[EmailService],
81+
AppContextProvider[UserDatabase]
82+
) {
83+
84+
85+
def subscribe(user: User): Unit =
86+
AppContext[EmailService].sendEmail(user, "You have been subscribed")
87+
AppContext[UserDatabase].insert(user)
88+
89+
90+
….
91+
92+
93+
}
94+
95+
96+
object UserSubscription {
97+
// boilerplate
98+
given (using AppContextProvider[EmailService],
99+
AppContextProvider[UserDatabase]):
100+
AppContextProvider[UserSubscription] with
101+
def get: UserSubscription = new UserSubscription
102+
}
103+
```
104+
105+
106+
107+
Okay, this works, but we have to write some boilerplate. Can we have the same in a shorter form, for example, a List of provided types instead of a set of implicit variants?
108+
109+
# Shrinking boilerplate:
110+
111+
Sure, we can pack the provided parameter types in the tuple and use macroses for extraction.
112+
113+
```
114+
class UserSubscription(using AppContextProviders[(EmailService,UserDatabase)]) {
115+
116+
117+
def subscribe(user: User): Unit =
118+
AppContext[EmailService].sendEmail(user, "You have been subscribed")
119+
AppContext[UserDatabase].insert(user)
120+
121+
122+
….
123+
124+
125+
}
126+
```
127+
128+
How to do this: at first, we will need a type-level machinery, which will select a first supertype of `T` from the tuple `Xs`:
129+
130+
```
131+
object TupleIndex {
132+
133+
134+
opaque type OfSubtype[Xs <: Tuple, T, N<:Int] = N
135+
136+
137+
extension [Xs <: Tuple, T, N<:Int](idx: TupleIndex.OfSubtype[Xs, T, N])
138+
def index: Int = idx
139+
140+
141+
inline given zeroOfSubtype[XsH, XsT <:Tuple, T<:XsH]: OfSubtype[XsH *: XsT, T, 0] = 0
142+
143+
144+
inline given nextOfSubtype[XsH, XsT <:NonEmptyTuple, T, N <: Int](using idx: OfSubtype[XsT, T, N]): OfSubtype[XsH *: XsT, T, S[N]] =
145+
constValue[S[N]]
146+
147+
148+
}
149+
```
150+
151+
152+
Then, we can define a type for search in AppProviders:
153+
154+
```Scala
155+
trait AppContextProvidersSearch[Xs<:NonEmptyTuple] {
156+
157+
158+
def getProvider[T,N<:Int](using TupleIndex.OfSubtype[Xs,T,N]): AppContextProvider[T]
159+
160+
161+
def get[T, N<:Int](using TupleIndex.OfSubtype[Xs,T, N]): T = getProvider[T,N].get
162+
163+
164+
}
165+
166+
167+
trait AppContextProviders[Xs <: NonEmptyTuple] extends AppContextProvidersSearch[Xs]
168+
169+
```
170+
171+
172+
and expression, which will assemble the instance of the AppContextProvider from available context providers when it will be called.
173+
174+
175+
```Scala
176+
object AppContextProviders {
177+
178+
inline given generate[T<:NonEmptyTuple]: AppContextProviders[T] = ${ generateImpl[T] }
179+
180+
……
181+
182+
}
183+
```
184+
185+
186+
(complete code is available in the repository: https://github.com/rssh/scala-appcontext )
187+
188+
We separate `AppContextProvidersSearch` and `AppContextProviders` because we don't want to trigger AppContextProviders' implicit generation during implicit search outside of service instance generation.
189+
Note that Scala currently has no way to make a macro that generates a given instance to fail an implicit search silently. We can only make errors during the search, which will abandon the whole compilation.
190+
191+
Can we also remove the boilerplate when defining the implicit AppContext provider?
192+
I.e.
193+
194+
```Scala
195+
object UserSubscription {
196+
// boilerplate
197+
given (using AppContextProvider[EmailService],
198+
AppContextProvider[UserDatabase]): AppContextProvider[UserSubscription] with
199+
def get: UserSubscription = new UserSubscription
200+
}
201+
```
202+
203+
Will become
204+
205+
206+
```Scala
207+
object UserSubscription {
208+
209+
given (using AppContextProviders[(EmailService, UserDatabase)]): AppContextProvider[UserSubscription] with
210+
def get: UserSubscription = new UserSubscription
211+
}
212+
```
213+
214+
But this will still be boilerplate: We must enumerate dependencies twice and write trivial instance creation. On the other hand, this instance creation is not entirely meaningless: we can imagine the situation when it's not automatic.
215+
216+
To minimize this kind of boilerplate, we can introduce a convention for AppContextProviderModule, which defines its dependencies in type and automatic generation of instance providers:
217+
218+
```
219+
trait AppContextProviderModule[T] {
220+
221+
222+
/**
223+
* Dependencies providers: AppContextProviders[(T1,T2,...,Tn)], where T1,T2,...,Tn are dependencies.
224+
*/
225+
type DependenciesProviders
226+
227+
228+
/**
229+
* Component type, which we provide.
230+
*/
231+
type Component = T
232+
233+
234+
235+
236+
inline given provider(using dependenciesProvider: DependenciesProviders): AppContextProvider[Component] = ${
237+
AppContextProviderModule.providerImpl[Component, DependenciesProviders]('dependenciesProvider)
238+
}
239+
240+
241+
242+
}
243+
```
244+
245+
246+
Now, the definition of UserSubscriber can look as described below:
247+
248+
```Scala
249+
class UserSubscription(using UserSubscription.DependenciesProviders)
250+
251+
252+
253+
object UserSubscription extends AppContextProviderModule[UserSubscription] {
254+
type DependenciesProviders = AppContextProviders[(EmailService, UserDatabase)]
255+
}
256+
```
257+
258+
259+
Is that all – not yet.
260+
261+
# Caching
262+
263+
Yet one facility usually needed from the dependency injection framework is caching. In all our examples, `AppContextProvider` returns a new instance of services. However, some services have a state that should be shared between all service clients. An example is a connection pool or service that gathers internal statistics into the local cache.
264+
265+
Let’s add cache type to the AppContext:
266+
267+
```
268+
object AppContext {
269+
270+
271+
272+
273+
274+
opaque type Cache = TrieMap[String, Any]
275+
276+
277+
opaque type CacheKey[T] = String
278+
279+
280+
inline def cacheKey[T] = ${ cacheKeyImpl[T] }
281+
282+
283+
extension (c: Cache)
284+
inline def get[T]: Option[T]
285+
inline def getOrCreate[T](value: => T): T
286+
inline def put[T](value: T): Unit
287+
288+
289+
}
290+
```
291+
292+
293+
And let's deploy a simple convention: if the service requires `AppContext.Cache` as a dependency, then we consider this service cached. I.e., with manual setup of AppContextProvider this should look like this:
294+
295+
```
296+
object FuelUsage {
297+
298+
given (using AppContextProviders[(AppContext.Cache, Wheel, Rotor, Tail)]): AppContextProvider[FuelUsage] with
299+
def get: FuelUsage = AppContext[AppContext.Cache].getOrCreate(FuelUsage)
300+
301+
}
302+
```
303+
304+
Automatically generated providers follow the same convention.
305+
306+
The cache key now is just the name of a type. But now we are facing a problem: if we have more than one service implementation (test/tangible), there are different types. Usually, developers consider some ‘base type’ that the service should replace. Hoverer macroses can’t extract this information indirectly. So, let’s allow a developer to write this class in the annotation:
307+
308+
```Scala
309+
class appContextCacheClass[T] extends scala.annotation.StaticAnnotation
310+
```
311+
312+
Cache operations will follow that annotation when calculating cacheKey[T].
313+
Typical usage:
314+
315+
```
316+
trait UserSubscription
317+
318+
@appContextCacheClass[UserSubscription]
319+
class TestUserSubscription(using TestUserSubscription.DependenciesProviders)
320+
321+
...
322+
```
323+
324+
#Preventing pitfalls
325+
326+
Can this be considered a complete mini-framework? Still waiting.
327+
Let’s look at the following code:
328+
329+
```Scala
330+
case class Dependency1(name: String)
331+
332+
333+
object Dependency1 {
334+
given AppContextProvider[Dependency1] = AppContextProvider.of(Dependency1("dep1:module"))
335+
}
336+
337+
338+
case class Dependency2(name: String)
339+
340+
class Component(using AppContextProvider[Dependency1,Dependency2]) {
341+
def doSomething(): String = {
342+
s”${AppContext[Dependency1]}:${AppContext[Dependency2]}
343+
}
344+
}
345+
346+
val dep1 = Dependency1("dep1:local")
347+
val dep2 = Dependency2("dep2:local")
348+
val c = Component(using AppContextProviders.of(dep1, dep2))
349+
println(c3.doSomething())
350+
```
351+
352+
What will be printed?
353+
354+
The correct answer is “dep1:module:dep2:local”, because resolving of Dependency1 from the companion object of Dependency1 will be preferred over resolving from the AppContextProvider companion object. Unfortunately, I don’t know how to change this.
355+
356+
We can add a check to determine whether supplied providers are needed. Again, unfortunately, we can’t add it ‘behind the scenes' by modifying the generator of AppContextProvider because the generator is inlined in the caller context for the component instance, where all dependencies should be resolved.
357+
We can write a macro that should be called from the context inside a component definition. This will require the developer to call it explicitly.
358+
359+
I.e., a typical component definition will look like this:
360+
361+
```
362+
class Component(using AppContextProviders[(Dependency1,Dependency2)]) {
363+
assert(AppContextProviders.allDependenciesAreNeeded)
364+
….
365+
}
366+
```
367+
368+
Now, we can use our micro-framework without pitfalls. Automatic checking that all listed dependencies are actual is a good idea, which can balance the necessity of the extra line of code.
369+
370+
371+
In the end, we have received something usable. After doing all the steps, I can understand why developers with the most expertise in another ecosystem can hate Scala.
372+
With any other language, a developer can get the default most-known dependency injection framework for this language and use one. But in Scala, we have no defaults. All alternatives are heavy or not type-driven. Building our small library takes time, distracting developers from business-related tasks. And we can’t eliminate the library users' need to write boilerplate code.
373+
On the other hand, things look good. Scala's flexibility allows one to quickly develop a ‘good enough’ solution despite the fragmented ecosystem.
374+
375+
376+
Repository for this mini-framework can be found at https://github.com/rssh/scala-appcontext
377+

0 commit comments

Comments
 (0)