├── .gitignore ├── .scalafmt.conf ├── README.md ├── build.sbt ├── project └── build.properties └── src └── main └── scala └── com └── example ├── Main.scala └── example.scala /.gitignore: -------------------------------------------------------------------------------- 1 | .bsp 2 | .idea 3 | target 4 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 2.7.5 2 | align.preset = more 3 | maxColumn = 120 4 | spaces.beforeContextBoundColon = Always 5 | newlines.implicitParamListModifierPrefer = before 6 | rewriteTokens = { 7 | "⇒": "=>" 8 | "→": "->" 9 | "←": "<-" 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Domain-Driven Design with FP in Scala 2 | 3 | This is a supplementary repository to the following article: https://bszwej.medium.com/domain-driven-design-with-fp-in-scala-21b557f94aa5 4 | 5 | ## Running 6 | Examples can be run with sbt: 7 | 8 | 1. Run sbt shell 9 | ```bash 10 | sbt 11 | ``` 12 | 2. Run the example 13 | ```shell script 14 | sbt:ddd-with-fp-in-scala> run 15 | ``` 16 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := "ddd-with-fp-in-scala" 2 | 3 | version := "0.1" 4 | 5 | scalaVersion := "2.13.3" 6 | 7 | libraryDependencies += "org.typelevel" %% "cats-effect" % "2.2.0" 8 | libraryDependencies += "org.typelevel" %% "cats-core" % "2.2.0" 9 | libraryDependencies += "eu.timepit" %% "refined" % "0.9.17" 10 | libraryDependencies += "com.beachape" %% "enumeratum" % "1.6.0" 11 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.4.2 -------------------------------------------------------------------------------- /src/main/scala/com/example/Main.scala: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import cats.effect.ExitCode 4 | import cats.effect.IO 5 | import cats.effect.IOApp 6 | import com.example.example.OrderCreationRequest 7 | import com.example.example.OrderHttpController 8 | 9 | object Main extends IOApp { 10 | 11 | override def run(args: List[String]): IO[ExitCode] = 12 | for { 13 | _ <- IO.unit 14 | 15 | // Successful order creation 16 | orderCreationRequest = OrderCreationRequest(customerId = "42", amount = BigDecimal(10), currency = "PLN") 17 | _ <- IO.delay(println(s"Creating order: $orderCreationRequest")) 18 | httpResponse <- OrderHttpController.create(orderCreationRequest) 19 | _ <- IO.delay(println(httpResponse)) 20 | 21 | // Error order creation 22 | orderCreationRequest = OrderCreationRequest( 23 | customerId = "", 24 | amount = BigDecimal(-100), 25 | currency = "invalid currency" 26 | ) 27 | _ <- IO.delay(println(s"Creating order: $orderCreationRequest")) 28 | httpResponse <- OrderHttpController.create(orderCreationRequest) 29 | _ <- IO.delay(println(httpResponse)) 30 | } yield ExitCode.Success 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/main/scala/com/example/example.scala: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import cats.data.EitherNel 4 | import cats.data.NonEmptyList 5 | import cats.effect.IO 6 | import cats.implicits._ 7 | import enumeratum.Enum 8 | import enumeratum.EnumEntry 9 | import eu.timepit.refined.collection.NonEmpty 10 | import eu.timepit.refined.numeric.NonNegative 11 | import eu.timepit.refined.refineV 12 | import eu.timepit.refined.types.all.NonNegBigDecimal 13 | import eu.timepit.refined.types.string.NonEmptyString 14 | 15 | object example { 16 | 17 | /** DTOs. 18 | * They can be used to e.g. model a raw request or an even in the infrastructure layer of the application. 19 | */ 20 | case class OrderCreationRequest(customerId: String, amount: BigDecimal, currency: String) { 21 | val toDomain: EitherNel[ValidationError, Order] = 22 | Order.create(customerId: String, amount: BigDecimal, currency: String) 23 | } 24 | 25 | /** Domain Model. 26 | * The business logic belongs here. This where we want to use more types, perform validation and check for invariants etc. 27 | */ 28 | sealed trait ValidationError 29 | case object EmptyCustomerId extends ValidationError 30 | case object NegativeAmount extends ValidationError 31 | case object InvalidCurrency extends ValidationError 32 | 33 | case class CustomerId(value: NonEmptyString) 34 | object CustomerId { 35 | def create(value: String): Either[ValidationError, CustomerId] = 36 | refineV[NonEmpty](value).bimap(_ => EmptyCustomerId, CustomerId(_)) 37 | } 38 | 39 | sealed trait Currency extends EnumEntry 40 | object Currency extends Enum[Currency] { 41 | case object PLN extends Currency 42 | case object EUR extends Currency 43 | case object GBP extends Currency 44 | 45 | val values = findValues 46 | } 47 | 48 | case class Money(amount: NonNegBigDecimal, currency: Currency) 49 | object Money { 50 | 51 | def create(amount: BigDecimal, currency: String): Either[NonEmptyList[ValidationError], Money] = 52 | ( 53 | refineV[NonNegative](amount) 54 | .leftMap(_ => NegativeAmount) 55 | .toEitherNel, 56 | Currency 57 | .withNameInsensitiveEither(currency) 58 | .leftMap(_ => InvalidCurrency) 59 | .toEitherNel 60 | ).parMapN(Money(_, _)) 61 | 62 | } 63 | 64 | case class Order(customerId: CustomerId, amount: Money) 65 | object Order { 66 | def create(customerId: String, amount: BigDecimal, currency: String): EitherNel[ValidationError, Order] = 67 | ( 68 | CustomerId.create(customerId).toEitherNel, 69 | Money.create(amount, currency) 70 | ).parMapN(Order(_, _)) 71 | } 72 | 73 | /** HTTP controller. 74 | * This can be for example tapir's server logic, http4s or akka routing DSL. 75 | * This is where we convert DTOs to the core domain model along with executing the validation logic. 76 | * We know exactly what to do with validation errors here. 77 | */ 78 | object OrderHttpController { 79 | def create(order: OrderCreationRequest): IO[HttpResponse] = IO { 80 | order.toDomain.fold( 81 | errors => HttpResponse(400, s"Errors found: ${errors.toList.mkString("(", ", ", ")")}"), 82 | _ => HttpResponse(200, "Order created") 83 | ) 84 | } 85 | 86 | case class HttpResponse(errorCode: Int, payload: String) 87 | } 88 | 89 | /** Order domain service. 90 | * Contains the main business logic and models business processes like Order creation. 91 | * It should accept only valid entities. 92 | */ 93 | object OrderService { 94 | def create(order: Order): IO[Either[OrderCreationError, Unit]] = { 95 | // If order could have been created, it's valid. 96 | // That means only valid orders can be processed here. 97 | IO.pure(Right(())) 98 | } 99 | 100 | sealed trait OrderCreationError 101 | } 102 | 103 | } 104 | --------------------------------------------------------------------------------