highlights
Apr 14, 2023

Reimagining the microservice: an early preview

Paul Chiusano, Fabio Labella

We've developed a nicer way to build microservices in Unison. This post is a quick preview of what you can expect.

🧪
This feature hasn't been released yet, and some details may change, but it's coming soon! If you like the sound of this, you can join the unison.cloud waitlist.

Building a microservice-powered backend today typically means taking on a lot of work that's not your application's business logic, and isn't very interesting or differentiated. For instance:

  • Packaging a particular version of your service into a container image
  • Deploying that container to some infrastructure where it can be run
  • Managing communication between services, via JSON, HTTP, protocol buffers, and the like
  • Dealing with serialization code at the boundary between the service and some durable storage layer
  • Ensuring proper registration, discovery, and communication between services using appropriate service discovery patterns, tools, and platforms
  • Handling failure and resiliency, for example in long-running workflows

Wouldn't it be nice if you could spend less time on these activities, and more time on the stuff that matters for your application?

A simple "Hello world" service

Let's have a look at a simple "Hello world" microservice (we'll just say "service" in the rest of this post), written and deployed to Unison Cloud with a few lines of code:

hello : HttpRequest -> HttpResponse
hello req = HttpResponse.ok (Body (Text.toUtf8 "Hello, world!"))

deployHelloWorld = do cloud.services.http.deploy hello

The service's logic is just a regular function which accepts an HttpRequest and returns an HttpResponse. We also use regular Unison code to deploy the service.

Doing run deployHelloWorld in UCM will create and deploy the service:

scratch/main> run deployHelloWorld

  Service deployed at https://3d03c8e7d82f1f4211e4d7762632c68.services.unison.cloud
  Visit https://services.unison.cloud to see all your running services.

  ServiceHash 0xs3d03c8e7d82f1f4211e4d7762632c68

There's no packaging step, no building containers, or anything like that. You just call a function in the Unison Cloud API to deploy the service. Unison automatically uploads your function and all of its dependencies to Unison Cloud, caching them on the server.

This service is now live on the internet. It's just like any other HTTP service and we can call it from the browser or from any other language. For instance, here's Python:

import requests

url = "https://3d03c8e7d82f1f4211e4d7762632c68.services.unison.cloud"
response = requests.get(url)

if response.status_code == 200:
  print(response.text)
else:
  print("Error: ", response.status_code)

Deployment of services takes mere seconds, and the Unison Cloud implementation of services doesn't actually use any resources until the service is called. The platform will run and scale the service for you, and coming soon, services will even be JIT-compiled for speedy performance.

Updating a service

Let's make a trivial change to our service and redeploy it in seconds:

deployHelloWorld2 = do
  logic req = HttpResponse.ok (Body (Text.toUtf8 "👋, world!"))
  cloud.services.http.deploy logic

Notice that the URL is different:

scratch/main> run deployHelloWorld

  Service deployed at https://0318cef3bf71ca76d9228e6d17f29bb21a.services.unison.cloud
  Visit https://services.unison.cloud to see all your running services.

  ServiceHash 0xs0318cef3bf71ca76d9228e6d17f29bb

By default, service URLs are based on ServiceHash, which is a hash of the service implementation. Using hashes as the identity of a service means that services are immutable and content-addressed: you don't modify a service, you instead deploy a new one.

We provide a separate layer for stably-named services whose implementations can evolve over time. (More on that in future posts)

Content-addressing is a recurring theme for Unison and content-addressed services have some nice properties. For instance, service deployment is idempotent, and we can deploy as many versions of the "same" service as we like without these versions interfering and without needing to set up multiple staging environments.

Services can use effects

Services don't have to just be pure functions from HttpRequest to HttpResponse. They can do lots of things, by leveraging Unison's abilities:

  • They can perform distributed computation using the Remote ability. Need to spawn a big map reduce job on the fly in response to a service request? Services can do that.
  • They can access secrets and configuration parameters specific to that service.
  • They can issue HTTP requests.
  • They can do logging, with logs consolidated and easily viewable in your Unison Cloud account.
  • (coming soon) They can access durable and scalable Unison-native storage, with no serialization boilerplate to persist and unpersist values.
  • And more...

Over time, we can easily grow the set of capabilities that services get access to.

For instance, let's add some logging to our "Hello World" service:

logic : HttpRequest ->{Log} HttpResponse
logic req = 
  log "Waving"
  HttpResponse.ok (Body (Text.toUtf8 "👋, world!"))

deployHelloWorld3 = do cloud.services.http.deploy logic

Typed services and inter-service calls without the boilerplate

While HTTP is still the primary interface for most public-facing services, a lot of backends have services which are only called internally. It's unfortunate to have to pay the price of encoding / decoding boilerplate at each of these service call boundaries, and we can do better in Unison.

So far we've been dealing with HTTP services, which have the shape HttpRequest ->{Remote} HttpResponse, but Unison supports typed services where the input and output types can be anything we like. To deploy one of these "Unison-native" services, we use a more general function cloud.services.deploy (instead of cloud.services.http.deploy).

cloud.services.deploy : (a ->{Remote} b) ->{IO,Exception} ServiceHash a b

The argument to this function has the shape a ->{Remote} b. deploy returns a ServiceHash a b , where a is the input type of the service and b is the output type. For example, a user lookup service might be represented by a ServiceHash Username User and in general your services can work with arbitrarily complex types. You can even have higher-order services that accept functions as arguments!

The big benefit here is that Unison-native service calls are typed, so there's no serialization code to write and you can never accidentally send the wrong type of data to a service. All you need to do is use:

Services.call : ServiceHash a b -> a ->{Services, Remote} b

For instance, here's an example of some service logic that finds a user's playlist by name and returns a list of the track titles for that playlist. There's no converting to and from JSON blobs or whatever else, you just call a function with a typed value and get back a typed reply:

serviceCallEx 
  : ServiceHash User [Playlist]
 -> ServiceHash Track Track.Title
 -> {Services, Remote} [Track.Title]
serviceCallEx playlists trackTitle =
  Services.call playlists (User "alice") 
    |> List.filter (p -> Playlist.name p == "Discovery Weekly")
    |> List.flatMap Playlist.tracks
    |> Remote.parMap (Services.call trackTitle)

Notice that we're even collecting all the track titles for the playlist in parallel.

If while writing this code, we make a mistake and try to send a value of the wrong type to a service call, we get a type error at compile time, not a runtime error sometime after the service has been deployed.

An architecture for Unison-based backends

Though it's still early days, we suspect that a common pattern when building backends with Unison will be to create a collection of typed, Unison-native services that can all call each other easily, and then a public-facing gateway service that speaks HTTP and delegates to the Unison-native services.

It's this gateway service that will be called by the front-end or by other non-Unison services within your organization. (This gateway service may also be where common concerns such as authentication and authorization are dealt with.)

Here's an example — this code creates a single HTTP service with two routes, each of which delegates to a different typed microservice:

serviceCalls : ServiceHash User Nat 
            -> ServiceHash User Nat 
            -> {IO,Exception} ServiceHash HttpRequest HttpResponse
serviceCalls bumpUserCount accessUserCount =  
  publicFacing req = 
    getCount = do
      Routes.get (root / "users" / "get-count")
      user = parseUser (header "user")
      n = Services.call accessUserCount user
      HttpResponse.ok (Body (n |> Nat.toText |> toUtf8))
    bumpCount = do
      Routes.post (root / "users" / "bump-count")
      user = parseUser (header "user")
      n = Services.call bumpUserCount user
      HttpResponse.ok (Body (n |> Nat.toText |> toUtf8))
    Http.handler (getCount <|> bumpCount <|> 'notFound)
  
cloud.services.http.deploy publicFacing

Notice how within the public-facing service, we can call the other services bumpUserCount and accessUserCount without any encoding and decoding boilerplate.

What's next?

Be on the lookout for future posts showing more of what's possible with this model, and posts with fully-worked example services that you can fork and use as templates for your own work (for instance "A simple Hello World Slackbot").

We'll be rolling out support for microservices on Unison Cloud in the coming months. If you're interested in getting access to the beta once it's available, sign up at unison.cloud.