Architectural Patterns
In this section, we are going to talk about the design elements of a ZIO application and the ZIO idiomatic way of structuring codes to write ZIO applications.
Onion Architecture​
Onion architecture is a software architecture pattern that is used to create loosely coupled, maintainable, and testable applications by layering the application into a set of concentric circles.
- The innermost layer contains the domain model. Its language has the highest level of abstraction.
- From the very center, the language domain model is surrounded successively by other layers, each of which is more technical and has a lower level of abstraction than the previous one.
- The outermost layer contains the final language is the one that is closest to the environment in which the application is running. For example, the outermost layer could be the user interface, a web API, etc.
Onion architecture is based on the inversion of control principle. So each layer is dependent on the underlying layer, but not on the layers above it. This means that the innermost layer is independent of any other layers.
In ZIO by taking advantage of both functional and object-oriented programming, we can implement onion architecture in a very simple and elegant way. To implement this architecture, please refer to the Writing ZIO Services section which empowers you to create layers (services) in the onion architecture. In order to assemble all layers and make the whole application work, please refer to the Dependency Injection In ZIO section.
Assembling Layers: Composition Operators​
Once each onion ring is expressed as a ZLayer, we need a way to wire the rings together into one application. ZLayer gives us a small set of composition operators for exactly this, and choosing the right one is an architectural decision, not just a syntactic one:
++— combines two layers in parallel, producing a layer that requires the union of their inputs and produces the union of their outputs. Use it to combine independent rings (or independent services on the same ring) that don't depend on each other.<*>— combines two layers sequentially, also producing the union of their inputs and outputs. Use it when combining two layers that don't feed into one another, but where you want to be explicit that they are wired one after the other rather than concurrently.>>>— feeds the output of one layer into the input of the next, producing a layer with the first layer's inputs and the second layer's outputs. This is how one onion ring is stacked directly on top of the ring beneath it.>+>— like>>>, but keeps the first layer's outputs around as well, so both rings' services remain in the environment. Use it when an outer ring needs direct access to an inner ring's service, not just the ring built on top of it.
For anything beyond a couple of layers, hand-composing with these operators becomes tedious and error-prone. ZLayer.make[T] builds the whole dependency graph for a target service T automatically from the layers you give it, in any order, failing at compile time if a dependency is missing or ambiguous:
val application: ZLayer[Any, Nothing, UserRepository] =
ZLayer.make[UserRepository](UserRepository.live, Database.live, ConnectionPool.live, Config.live)
ZLayer.make figures out that UserRepository needs Database, Database needs ConnectionPool, and ConnectionPool needs Config, and assembles >>> and >+> chains for you. For the full construction API, including partial and automatic wiring, refer to the Dependency Injection In ZIO section.
Streaming Architecture​
Many reasons make streaming architecture a good choice for building applications:
- From the technical perspective when we are dealing with files, sockets, HTTP requests, databases, etc we are working with streams of data.
- In addition from a business standpoint, the area of data processing is growing rapidly and the need for processing continuous streams of data is increasing, such as real-time analytics, fraud detection, monitoring, social media platforms, financial trading, etc.
In such cases, we may decide to use streaming architecture. ZIO Streams is a library that provides a purely functional, composable, and type-safe way to work with streams of data. We can use ZIO Streams to model both stateful and stateless streaming data processing pipelines.
ZIO Streams is on top of ZIO. So we can think of ZStream as a specialized functional effect that has more power than ZIO. It is built on top of ZIO and supports backpressure using a pull-based model. To learn more about ZIO Streams, please refer to the ZIO Streams section.
Sidecar Pattern​
The sidecar pattern is a microservice architecture pattern that is used to separate cross-cutting concerns from the main business logic. It is a very useful pattern when we have to deal with concerns like logging, metrics, profiling, monitoring, etc. These concerns are not part of the main service logic, but they are important for the service to work correctly.
In ZIO, we can implement the sidecar pattern by using compositional apps, or by using the bootstrap layer.
Composable ZIO Applications​
In the following example, as we have multiple applications (UserApp and DocumentApp), we use compositional apps to implement this pattern:
import zio._
import zio.http._
import zio.metrics.connectors.prometheus.PrometheusPublisher
import zio.metrics.connectors.{MetricsConfig, prometheus}
object UserApp extends ZIOAppDefault {
def run = Server.serve(userHttpApp).provide(Server.defaultWithPort(8080))
}
object DocumentApp extends ZIOAppDefault {
def run = Server.serve(documentHttpApp).provide(Server.defaultWithPort(8081))
}
object Metrics extends ZIOAppDefault {
private val metricsConfig = ZLayer.succeed(MetricsConfig(5.seconds))
def run =
Server
.serve(
Routes(Method.GET / "metrics" ->
handler(ZIO.serviceWithZIO[PrometheusPublisher](_.get.map(Response.text)))
)
)
.provide(
Server.defaultWithPort(8082),
metricsConfig,
prometheus.publisherLayer,
prometheus.prometheusLayer
)
}
object MainApp extends ZIOApp.Proxy(UserApp <> DocumentApp <> Metrics)
Bootstrap Layer​
If we had only one application, we could use the bootstrap layer to implement this pattern:
import zio._
import zio.http._
import zio.metrics.connectors.prometheus.PrometheusPublisher
import zio.metrics.connectors.{MetricsConfig, prometheus}
object MetricsService {
private val metricsConfig = ZLayer.succeed(MetricsConfig(5.seconds))
private val exporter: ZLayer[PrometheusPublisher, Nothing, Unit] =
ZLayer.fromZIO {
Server
.serve(
Routes(Method.GET / "metrics" ->
handler(ZIO.serviceWithZIO[PrometheusPublisher](_.get.map(Response.text)))
)
)
.provideSome[PrometheusPublisher](Server.defaultWithPort(8081))
.forkDaemon
.unit
}
val layer: ZLayer[Any, Nothing, Unit] =
ZLayer.make[Unit](
exporter,
metricsConfig,
prometheus.publisherLayer,
prometheus.prometheusLayer
)
}
object UserApp extends ZIOAppDefault {
override val bootstrap = MetricsService.layer
def run = Server.serve(userHttpApp).provideSome(Server.defaultWithPort(8080))
}
Both examples above generalize beyond metrics: the same sidecar shape — a self-contained ZLayer exposing its own HTTP route, wired in either as a composed app or via bootstrap — is how you'd add logging or tracing sidecars too, not just a Prometheus exporter. See the Developer Productivity section for the broader observability picture (logging, tracing, and metrics together), and ZIO Logging for a sidecar-ready logging library built the same way.
Module Boundaries and Scaling​
The composable-apps example above (UserApp <> DocumentApp <> Metrics) raises a natural question: when should a growing application stay a single ZIOAppDefault with more layers, and when should it split into multiple, independently composed apps?
- One app, more layers — while the application shares a single lifecycle (it starts, runs, and shuts down together) and its services are used across most of the codebase, keep it as one
ZIOAppDefaultand grow itsZLayergraph. The Onion Architecture layering already gives you separation of concerns; you don't need separate apps to get separate modules. - Multiple composed apps — once a part of the system has its own independent lifecycle (it can be deployed, scaled, or restarted separately, like the
Metricsapp above), or serves a distinct bounded context with little shared code, model it as its ownZIOAppDefaultand compose it with the others via<>, as the Sidecar Pattern section does.
Either way, module boundaries in a ZIO application are ZLayer boundaries: a module is a service (or a small family of services) exposed as a layer, with its own live implementation and its own dependencies declared through the constructor pattern from the Writing ZIO Services section. Whether you organize the codebase by bounded context (one package per module, each owning its layers end-to-end) or by architectural layer (one package per onion ring, shared across modules) is a project-structure decision independent of this — both compose the same way through ZLayer.