From my experience Scala is quite a unique language because you have a large divide between users who are largely coming from Java and using OOP but taking advantage of the Scala algebraic data types and some monads like Future
and Option
, and users who are using pure FP design who are often using ad-hoc polymorphism with typeclasses.
Each approach has its own way of addressing dependencies.
The first approach I carried over from Java and now use regularly with a mixed-experience team on a large Scala system. First you define interfaces with trait
and then you implement them with classes:
trait Primary {
def alpha(v: A): B
def beta(v: A): C
}
trait Secondary {
def one(a: A, d: DD): EE
def two(d: DD): FF
}
trait Service {
def makeFoo(): Baz
}
class DefaultPrimary {
override def alpha(v: A): B = ???
override def beta(v: A): C = ???
}
class DefaultSecondary(primary: Primary) {
override def one(a: A, d: DD): EE = EE(primary.alpha(a), primary.beta(a), two(d))
override def two(d: DD): FF = ???
}
class DefaultService(secondary: Secondary) {
override def makeFoo(): Baz = {
val e = secondary.one(A(..), DD(..))
Baz(Foo(..), e)
}
}
// at composition root choose your dependency injection pattern and apply it
val service = new DefaultService(new DefaultSecondary(new DefaultPrimary()))
// later
service.makeFoo()
There's nothing wrong with this approach. We've got loosely coupled dependencies, proper dependency injection, and everything is very testable.
With what I would call a more FP approach, you define typeclasses which describe a category of behavior that something can exhibit. You inject the dependencies through implicit
values and you would import them at the composition root.
trait Primary[T] {
def alpha(v: T): B
def beta(v: T): C
}
trait Secondary[T] {
def one(d: T, a: A): EE
def two(d: T): FF
}
trait Service[T] {
def makeFoo(): Baz
}
object Instances {
implicit val aPrimaryInstance: Primary[A] = new Primary[A] {
override def alpha(v: A): B = ???
override def beta(v: A): C = ???
}
implicit def ddSecondaryInstances(implicit primary: Primary[A]): Secondary[DD] = new Secondary[DD] {
override def one(d: DD, a: A): EE = EE(primary.alpha(a), primary.beta(a), two(d))
override def two(d: DD): FF = ???
}
}
class DefaultService(implicit secondary: Secondary[DD]) {
override def makeFoo(): Baz = {
val e = secondary.one(A(..), DD(..))
Baz(Foo(..), e)
}
}
// at composition root dependencies are wired by implicit resolution
import Instances._
val service = new DefaultService
// later
service.makeFoo()
The second approach isn't all that different from the first approach, but we have looser-coupling because Primary
is polymorphic on type and our dependencies are wired for us with the Scala compiler's implicit resolution at compile time. The code is less-approachable to those coming from OOP, and the extra polymorphism isn't always useful unless you're writing a library.
Whether the dependency problem emerges in the Secondary module, or, if thePrimary functions are independent and won't change, only in the Service module
Generally I believe importing implementation like you're doing is a problem regardless whether you believe something will change or not. By importing Primary
's implementation in Secondary
it makes it very difficult to test Secondary
in isolation.
Is the use of import here a design smell (and very different, eg, from importing java.math)?
Yes it's a design smell, and no it isn't different from importing java.math
. When you import your dependencies you tightly-couple yourself to those dependencies regardless of whether they're standard lib or not. At a certain point you must settle with a degree of coupling, and usually with standard libs we expose ourselves to less harm because we assume they are mature, well-tested, and every user of the class will also have access to the standard libraries. I think using typeclasses can reduce tight-coupling more, but I'm not sure if that's the most compelling reason to use them.
Would some of these work, in large applications, or be an overkill?
Make Service call the methods in Primary and supply the results to Secondary. Then Secondary is independent, but Service is exposed to lower-level details
I think that's a broader design decision. Should Service
orchestrate everything, or do you want a more layered design? You've only shifted the concern and now you must figure out how to inject both a Primary
and Secondary
into Service
.
Currying and supplying functions as arguments. This increases the number of method parameters and possibly exposes implementation details
Yes, I think you could take this approach and have a nice, decoupled design, but I'm not sure how well it will scale on a large system. I think of currying as the parallel of constructor parameters for individual functions. The trick if you take this approach is that you should partially apply the functions at the composition root. E.g.:
object Primary{
def alpha(v: A): B = ... // maths
def beta(v: A): C = ...
}
object Secondary{
type PrimaryAlpha = A => B
type PrimaryBeta = A => C
def one(primaryAlpha: PrimaryAlpha, primaryBeta: PrimaryBeta)(a: A, d: DD): EE = EE(primary.alpha(a), primary.beta(a), two(d))
def two(d: DD): FF = ???
}
object Service {
type SecondaryOne = (a: A, d: DD) => EE
def makeFoo(secondaryOne: SecondaryOne)(): Baz = {
val e = secondaryOne(A(..), DD(..))
Baz(Foo(..), e) }
}
// at composition root partially apply functions and wire things together
type ServiceMakeFoo = () => Baz
val secondaryOne: SecondaryOne = Secondary.one(Primary.alpha, Primary.beta) _
val service: ServiceMakeFoo = Service.makeFoo(secondaryOne) _
I think this is a very simple design, but you aren't using much of the power of the language, and it's arguable harder to follow.
Turn both maths modules into traits and define object MathService extends Primary with Secondary. This is injected either in makeFoo() or into Service, which would become a class. However, this could expose Service to unnecessary methods, violating "Interface Segregation Principle"
I've seen this approach quite often, and it's really bad because it encourages lots of tight-coupling and inheritance. You're no better off than importing your dependencies. As you said it violates ISP.
Full-on composition: object Program extends MathService with Service. This is in line with the algebraic approach in "Functional and Reactive Domain Modeling" by D. Ghosh. My reservation is about the cohesion of composing modules across bounded contexts
I've only read a couple chapters of the book, so I'm not entirely sure what the approach you're describing looks like, but an alternative to the two approaches I've described above is the free-monad approach which is described in that book. I've used this approach at my last company, and it worked really well. You define your behavioural interfaces as a free-monad algebra and you implement the behavior with interpreters. If you're keen to try it out, I would look at Freestyle which drastically cuts the boilerplate of defining and implementing free-monads. The free-monad API is very similar to the first approach I described above, but it has two key advantages.
- Your operations are data - this means you can log them, store them in a DB, implement event sourcing, send them as messages over the network, etc.
- You can implement cross-cutting concerns.
The disadvantage with this approach is that you kinda need to pull in external dependencies for free-monads e.g. Cats
Cake patterns
You can use the Cake pattern. It's the same as the first approach I've given above expressed slightly differently. Constructor parameters are just a personal preference, and I think they're more familiar to folks coming from Java.
Reader Monad
To be honest I haven't used the reader monad often. Where I've seen it used is for DI at the composition root. I wouldn't advise having reader monads throughout the codebase only because I haven't seen an example that does so effectively.
Standard DI containers, but for functional modules
You can use any DI container, but it's important that the DI container doesn't leak everywhere in your codebase. You should apply DI containers at the composition root and you can do that with the first approach above. I've chosen "poor-mans" DI, but you can swap in a DI container.