Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline

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 LiveView 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:

  1. define a service trait;

  2. accept that service in the LiveView constructor;

  3. derive a LiveView layer with ZLayer.fromFunction;

  4. 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:

scala
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:

scala
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:

scala
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:

scala
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:

scala
object ReportsLiveView:
  val layer: URLayer[Reports, ReportsLiveView] =
    ZLayer.fromFunction(ReportsLiveView.apply)

Register that layer instead of constructing the LiveView manually:

scala
val reportsRoute =
  (live / "reports") -> ReportsLiveView.layer

The route operator 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:

scala
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:

scala
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:

scala
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.

Source
scala
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)
  )
Source
scala
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 ReportsExample

Try the embedded preview or open the real layer-backed route from the reports service injection example.