Services and dependency injection
Prerequisites
Start with a working Live route whose mount and handleMessage methods return
Task effects.
Inject A Service Into A LiveView
Use constructor injection when a LiveViewtrait LiveView[Msg, Model]Defines a server-rendered view with typed messages and model state.
needs an application service such as a repository, API client, or mailer. The
constructor makes the dependency explicit, while a ZIO layer supplies its
implementation when the application starts.
In the types used below, ZIO[R, E, A] needs environment R, may fail with
E, and may produce A; Task[A] uses Throwable as its error and UIO[A]
cannot fail. A ZLayer[RIn, E, ROut] constructs ROut from RIn, while
ULayer and URLayer are infallible aliases.
The complete path has four parts:
define a service trait;
accept that service in the LiveView constructor;
derive a LiveView layer with
ZLayer.fromFunction;provide the service implementation to the assembled routes.
Define The Service Boundary
Expose the operations the LiveView needs instead of passing low-level clients or mutable references into UI code. For example, a reports page can depend on this service:
final case class Report(id: Long, title: String)
trait Reports:
def recent: Task[Vector[Report]]Use an error type appropriate to the application. Task works when callers
handle arbitrary operational failures. A more specific IO[ReportsError, A]
can preserve domain failures until the LiveView maps them into user-facing state.
Implementations belong outside the LiveView. This small in-memory layer is useful for local development and tests:
object Reports:
val inMemory: ULayer[Reports] =
ZLayer.succeed(new Reports:
def recent = ZIO.succeed(
Vector(
Report(1L, "Daily sales"),
Report(2L, "Open incidents")
)
)
)A production layer can instead acquire a database pool or HTTP client and expose
the same Reports interface. If it acquires resources, build it with
ZLayer.scoped so ZIO releases them when the application shuts down.
Capture The Service In The Constructor
Accept the service in the LiveView constructor and use it from lifecycle callbacks:
final class ReportsLiveView(reports: Reports)
extends LiveView[ReportsLiveView.Msg, ReportsLiveView.Model]:
import ReportsLiveView.*
def mount(ctx: MountContext): Task[Model] =
reports.recent.map(Model.Loaded.apply)
def handleMessage(model: Model, ctx: MessageContext) =
case Msg.Refresh => reports.recent.map(Model.Loaded.apply)
def view(model: Signal[Model]) =
val loaded = model.map {
case Model.Loaded(reports) => Some(reports)
}
div(
button(on.click(Msg.Refresh), "Refresh"),
loaded.option { reports =>
ul(reports.splitBy(_.id) { (_, report) =>
li(report.map(_.title))
})
}
)Callbacks return Task, so the constructor-captured service is directly available
to mount and handleMessage; callback environment types do not need to change.
Define the model and messages as usual:
object ReportsLiveView:
enum Msg:
case Refresh
enum Model:
case Loaded(reports: Vector[Report])Derive And Register The LiveView Layer
Derive an infallible LiveView layer from its constructor:
object ReportsLiveView:
val layer: URLayer[Reports, ReportsLiveView] =
ZLayer.fromFunction(ReportsLiveView.apply)Register that layer instead of constructing the LiveView manually:
val reportsRoute =
(live / "reports") -> ReportsLiveView.layerThe route operatordef ->: [Msg, Model](view: => LiveView[Msg, Model])(LiveRoute[Any, A] { type Input = Any }) adds Reports
to the route environment. If the application does not provide a Reports layer,
the server startup effect cannot compile with a fully provided environment.
ZLayer.fromFunction also handles multiple constructor dependencies. A
LiveView constructed with (reports: Reports, audit: AuditLog) produces a layer
requiring both services; no manual environment lookup is necessary.
Provide Services At Startup
Build the router, combine it with any ordinary HTTP routes, and provide shared service layers where the server starts:
val routes = Live.router(reportsRoute)
Server.serve(routes).provide(
Server.default,
Reports.inMemory
)In a production application, replace Reports.inMemory with the production
implementation. Provide database pools, HTTP clients, repositories, and other
long-lived resources at this boundary. This keeps construction in one place and
lets ZIO report missing dependencies before the server can run.
Handle Service Failures In The Model
An unhandled service failure fails the LiveView lifecycle or message operation. Recover when the user can act on the failure, and represent that state explicitly in the model:
def load: UIO[Model] =
reports.recent
.map(Model.Loaded.apply)
.catchAll(_ => ZIO.succeed(Model.Failed("Reports are temporarily unavailable.")))Prefer a safe user-facing message over displaying raw exception details. Log the underlying cause with request or correlation context where the application can diagnose it. Keep retry as a typed message that invokes the service again.
Understand Service And LiveView Lifetimes
The service layer provided to Server.serve is normally built once and shared by
the routes that require it. Scalive builds the route's LiveView layer separately
for disconnected rendering and connected mount. Both LiveView objects may refer
to the same shared service, but each socket owns its own immutable model and
lifecycle resources.
Do not provide a prebuilt LiveView as an application service. Register its constructor-derived layer so Scalive can create the lifecycle instances it needs.
A shared service may own mutable or durable application state, but it must apply the application's authorization and isolation rules. Scope records by tenant or user when required, bound retained data, and use concurrency-safe implementations. Visitor-specific presentation state still belongs in the LiveView model.
Supply A Test Implementation
Tests can replace the production service without changing the LiveView:
val testReports = ZLayer.succeed(new Reports:
def recent = ZIO.succeed(Vector(Report(42L, "Fixture report")))
)
val viewLayer = ReportsLiveView.layer.provide(testReports)Use fixed results for rendering and message tests. Add failing or delayed test implementations when verifying error, retry, replacement, or cancellation behavior.
Explore The Runnable Example
The reports example applies the complete structure from this guide. It handles loaded, empty, and failed service results; refresh queries the service again, while reset changes only connection-local selection state.
final case class Report(id: Long, title: String, summary: String)
trait Reports:
def recent: Task[Vector[Report]]
object Reports:
val fixtures = Vector(
Report(1L, "Daily sales", "Revenue increased 8% over yesterday."),
Report(2L, "Open incidents", "3 incidents need an owner.")
)
val inMemory: ULayer[Reports] = ZLayer.succeed(
new Reports:
def recent: Task[Vector[Report]] = ZIO.succeed(fixtures)
)View source (documentation/site/src/scalive/docs/examples/ReportsExample.scala:8-22)
final class ReportsExample(reports: Reports)
extends LiveView[ReportsExample.Msg, ReportsExample.Model]:
import ReportsExample.*
def mount(ctx: MountContext): Task[Model] =
load
def handleMessage(model: Model, ctx: MessageContext) =
case Msg.Select(report) if model.contains(report) =>
ZIO.succeed(model.select(report))
case Msg.Select(_) => ZIO.succeed(model)
case Msg.ResetSelection => ZIO.succeed(model.resetSelection)
case Msg.Refresh => load
override def view(model: Signal[Model]): HtmlElement[Msg] =
val loaded = model.map {
case Model.Loaded(reports, selected) => Some((reports, selected))
case _ => None
}
div(
cls := "docs-reports-example",
div(
cls := "docs-reports-toolbar",
dataAttr("example-controls") := "",
div(
cls := "docs-reports-toolbar-copy",
strong("Reports workspace"),
span("Service-backed data with connection-local selection.")
),
div(
cls := "docs-reports-toolbar-actions",
button(
cls := "docs-reports-refresh",
typ := "button",
on.click(Msg.Refresh),
"Refresh reports"
),
a(
cls := "docs-reports-lab-link",
href := "/examples/service-injection/lab",
"Open layer-backed route"
)
)
),
loaded.option { loaded =>
val reports = loaded.map(_._1)
val selected = loaded.map(_._2)
sectionTag(
cls := "docs-reports-console",
aria.label := "Reports",
navTag(
cls := "docs-reports-picker",
aria.label := "Available reports",
p(cls := "docs-reports-picker-label", "Available reports"),
reports.splitBy(_.id) { (_, report) =>
val isSelected = report.zip(selected).map { case (report, selected) =>
report == selected
}
button(
cls := "docs-report-option",
typ := "button",
dataAttr("report-id") := report.map(_.id.toString),
dataAttr("selected") := isSelected.map(_.toString),
aria.pressed := isSelected.map(_.toString),
on.click(report.map(Msg.Select(_))),
span(cls := "docs-report-option-title", report.map(_.title)),
span(
cls := "docs-report-option-state",
isSelected.map(if _ then "Selected" else "View report")
)
)
}
),
articleTag(
cls := "docs-report-detail",
dataAttr("report-card") := selected.map(_.id.toString),
p(cls := "docs-report-detail-label", "Selected report"),
h4(
cls := "docs-report-detail-title",
dataAttr("report-selected") := "",
selected.map(_.title)
),
p(
cls := "docs-report-detail-summary",
dataAttr("report-summary") := "",
selected.map(_.summary)
),
footerTag(
span("Report ID"),
code(selected.map(report => s"#${report.id}"))
)
)
)
},
model
.map(_ == Model.Empty).when(
div(
cls := "docs-reports-status docs-reports-status-empty",
dataAttr("reports-empty") := "",
strong("No reports are available."),
span("Refresh to ask the service again.")
)
),
model
.map(_ == Model.Failed).when(
div(
cls := "docs-reports-status docs-reports-status-failed",
dataAttr("reports-failed") := "",
strong("Reports are temporarily unavailable."),
span("Refresh to retry the service request.")
)
)
)
end view
private def load: UIO[Model] =
reports.recent
.map(Model.from)
.catchAll(_ => ZIO.succeed(Model.Failed))
end ReportsExample
object ReportsExample:
val LabRoute = "/examples/service-injection/lab"
val route =
(live / "examples" / "service-injection" / "lab")
.from((_, _, reports: Reports) => ReportsExample(reports))
enum Model:
case Loaded(reports: Vector[Report], selected: Report)
case Empty
case Failed
def contains(report: Report): Boolean = this match
case Loaded(reports, _) => reports.contains(report)
case _ => false
def select(report: Report): Model = this match
case Loaded(reports, _) => Loaded(reports, report)
case other => other
def resetSelection: Model = this match
case Loaded(reports, _) => Loaded(reports, reports.head)
case other => other
object Model:
def from(reports: Vector[Report]): Model =
reports.headOption.fold[Model](Empty)(Loaded(reports, _))
enum Msg:
case Select(report: Report)
case ResetSelection
case Refresh
end ReportsExampleView source (documentation/site/src/scalive/docs/examples/ReportsExample.scala:29-182)
Try the embedded preview or open the real layer-backed route from the reports service injection example.
Related Tasks
Share an authentication store between HTTP and Live routes in Authentication and sessions.
Move finite or long-lived work under lifecycle ownership with Asynchronous work and subscriptions.
Replace production layers in Testing LiveViews.