Domain-Driven Microservices with F#
It is well-known, or at least well-documented, that F# is an excellent language for doing type-driven development using domain driven design, although it may be less well known that F# is an excellent language for microservices. Indeed, microservices benefit greatly from F#'s typesystem - making it easy to focus on a domain and its related flows within the context of a specific event or function.
Serverless Microservices in F#
First I should mention that AWS is an excellent home for F# developers, as Lambda supports .NET runtime and has a ton of excellent templates to get you started. Many of the templates support both C# and F#, and even in the ones where F# is absent, following the blueprint and converting it to F# usually isn't too much of a chore. A few highlights of using AWS Lambda with F# include: Hosting a full Giraffe API with API Gateway, SNS functions, SQS functions, and Kinesis functions. These templates paired with AWS Lambda Tools and AWS-SAM, generating and deploying a microservice in F# is a breeze.
Here is an example of an empty SNS function that just logs the payload:
namespace SNSFunction
open Amazon.Lambda.Core
open Amazon.Lambda.SNSEvents
// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class.
[<assembly: LambdaSerializer(typeof<Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer>)>]
()
type Function() =
/// <summary>
/// This method is called for every Lambda invocation. This is triggered by an SNS event
/// </summary>
/// <param name="event"></param>
/// <param name="context"></param>
/// <returns></returns>
member _.FunctionHandler (event: SNSEvent) (context: ILambdaContext) =
task {
event.Records
|> Seq.iter (fun record ->
$"Processed record {record.Sns.Message}"
|> context.Logger.LogInformation)
return "Finished"
}
While .NET and F# are both first-class citizens on AWS Lambda, you can use it on other clouds as well (although I have not personally). Angel Munoz recently demonstrated F# serverless functions on Google Cloud.
Domain-Driven Microservices
Now, if you come from a background where DTO has been in your or your codebase's vocabulary (like me), then it may take some adjustment before you realize the power at your disposal in F# and its type system in a microservice or multi-app environment. What I'm about to propose is to ship the domain directly over the wire, which is what DTO specifically avoids - this may seem offensive or counter-intuitive, but in my opinion shipping the domain in F# with its algebraic data types is very different thing than shipping the objects that live in a folder called "domain" in a C# project.
At any rate, for me it's taken some time to really reshape how I think about this, but once I started thinking of the entire system from a domain-driven approach, my designs have become more robust with each new feature.
Domains Over The Wire
So, when I mention shipping domains over the wire - I'm talking Scott Wlaschin-style types, defined according to a ubiquitous language with domain experts, where the types have function. We can do this by serializing F# discriminated unions in a way which can be deserialized easily by both F# and other languages, if need be. For this I use Microsoft.FSharpLu.Json
. With FSharpLu, you can utitize Compact.serialize
and Compact.deserialize
which, as the names suggest, is capable of representing a discriminated union in a compact way. This allows for you to 1) easily serialize and deserialize discriminated unions amongst F# microservices, and 2) build discriminated unions in applications that are written in JavaScript/TypeScript, serialize them with JSON.stringify, and deserialize them in F# as a fully functional discriminated union (and vice-versa).
Let's say you have an API that handles two flows which trigger a person receiving one of two emails. One flow is when a user registers for an account, and the other one is when user's manager gives them a class assignment that they need to complete. The former sends them a "complete registration" email, the latter sends them a "you have a new assignment" email. Your types for user
and assignment
were designed in a DDD session and may look like this:
type User =
{ firstName: string
lastName: string
email: string }
type Assignment =
{ name: string
email: string
dueDate: DateTime
userID: Guid }
Now let's say you have a serverless SNS function which accepts a type of Email
, also a type that was designed in a domain driven setting:
type Email =
| CompleteRegistration of User
| AssignmentNotification of Assignment
These types are defined in a shared project within your solution, or in some way that both the API and the service can access the type. So in your API endpoint in which you handle user registration, you invoke an AWS SNS event with the payload CompleteRegistration user
, and likewise in your assignment handler you dispatch AssignmentNotification assignment
.
Serializing with FsharpLu would look something like this:
user |> CompleteRegistration |> Compact.serialize
Then, back in your service (the SNS function), the body of the function is simply a pattern match:
open Amazon.Lambda.Core
open Amazon.Lambda.SNSEvents
open Microsoft.FSharpLu.Json
member _.FunctionHandler (event: SNSEvent) (context: ILambdaContext) =
task {
event.Records
|> Seq.iter (fun record ->
$"Processed record {record.Sns.Message}"
|> context.Logger.LogInformation
let deserialized =
record.Sns.Message
|> Compact.deserialize<Email>
match deserialized with
| CompleteRegistration user -> prinfn "send email A"
| AssignmentNotification assignment -> prinfn "send email B")
return "Event processed"
}
This SNS function's pattern matching can nearly mimic handler functions in a traditional API - just as a Giraffe API has a list of routes and handlers, this SNS function has a list of handlers in a pattern matching expression. All this because we are receiving and deserializing a first-class data type - a type that is semantic, terse, and defined according to a ubiquitous domain language. The SNS function itself is readable in it's entry point, perhaps even by a product owner who doesn't code!
In addition to this email microservice, say you have a notification service, or an "achievement" service - whatever it is, your services can be broken up by their domain boundary, and they can be completely type-driven from the first lines of their entry point!
Conclusion
In the example microservice above, your event's logic is driven by the domain - and it's not a plain object that represents a database table or entity (aka not like the POCOs in the "domain" directory) . Rather, it's a type that that has meaning and function - not behavior but intention and direction - and has been named according to ubiquitous language which can easily be in common with non-developers.
The beauty and simplicity of using F# is in its focus on types, functions, and the domain. Within a single application this is easy to see, but it can be obfuscated with excessive abstractions and patterns - especially in a microservice environment where things can feel very abstract. But when you realize that the focus on functions and types can cross the boundaries of individual applications - that they can be shared across microservices - then you can more easily maintain that same feeling of simplicity and expressiveness.