Keep application boundaries typed until the edge
Why returning JsValue from a service and hiding data shapes behind aliases can weaken a Scala application—and how to keep Play HTTP, domain and persistence contracts explicit.
A boundary should explain what crosses it
A method signature is one of the fastest ways to understand a Scala system. It should say what the caller must provide, what successful result can be produced and which expected failures need handling. When the signature exposes only String, tuple-heavy aliases or JsValue, much of that information has moved into the implementation.
This matters most at application boundaries: HTTP input entering a Play application, an application service invoking domain behaviour, a repository loading stored data or an external API returning a response. Each boundary has a representation and a set of guarantees. Treating them as the same model can make the first implementation shorter while making later changes harder to locate.
A useful default is to parse untrusted data at the edge, work with meaningful Scala types inside the application and serialize the result when it leaves. This is a design direction rather than a demand for a separate model in every directory.
HTTP request
↓ Play Reads and request validation
Transport request
↓ translation and domain construction
Application command
↓ service and domain behaviour
Typed application result
↓ response mapping and Play Writes
HTTP responseJsValue is a transport representation, not a domain result
Play’s JsValue is useful because it can represent any valid JSON value. That flexibility is also why it is a weak contract for most application services. Future[JsValue] says that an asynchronous JSON value will arrive; it does not say which fields exist, what they mean, whether the operation can be rejected or which shape represents success.
To understand such a method, an engineer has to find the implementation, identify the value passed to Json.toJson, locate the selected Writes and check whether later code modifies the JSON. The compiler cannot tell a caller that an expected field was renamed or that two successful shapes are possible.
Returning JSON also couples otherwise reusable application logic to Play. A scheduled process, integration test or another adapter must now understand an HTTP representation even when it only needs the account or decision produced by the operation.
type ServiceResponse = JsValue
final class AccountService(repository: AccountRepository) {
def find(id: String): Future[ServiceResponse] =
repository.find(id).map { account =>
Json.toJson(account)
}
}
// Is a missing account null, an empty object, an error object
// or a failed Future? The signature cannot answer.Return the application result the caller needs
A stronger service accepts validated inputs and returns a type describing the operation. A domain identifier prevents unrelated strings from being interchanged. A sealed error type names expected negative outcomes. A result case class states the successful data without committing it to JSON field names.
Future and Either represent different concerns. Future carries asynchronous completion or operational failure. Either carries an expected application decision. Future[Either[FindAccountError, AccountSummary]] is more verbose than Future[JsValue], but each branch now has a meaning the compiler and caller can see.
The service result does not have to expose the entire persisted entity. Return the information promised by this operation. A smaller result also reduces the chance that adding a database field accidentally expands the HTTP response.
final case class AccountId private (value: String) extends AnyVal
sealed trait FindAccountError
object FindAccountError {
final case class NotFound(id: AccountId) extends FindAccountError
case object AccessDenied extends FindAccountError
}
final case class AccountSummary(
id: AccountId,
name: AccountName,
balance: BigDecimal
)
trait AccountService {
def find(
id: AccountId
): Future[Either[FindAccountError, AccountSummary]]
}Translate the result at the HTTP boundary
The controller or an HTTP-specific presenter should translate the application result into status codes and response bodies. This is where NotFound becomes 404, AccessDenied becomes 403 and AccountSummary becomes the public JSON representation.
Keeping that translation at the boundary does not require a large controller. A response mapper can own the conversion when it is reused or complicated. The architectural point is that this component is explicitly part of the HTTP adapter, while the application service remains unaware of Result, JsValue and HTTP status codes.
This split makes tests more focused. Service tests assert typed results. Controller tests assert parsing, status codes and JSON. A change to an API field does not require rewriting domain assertions, and a new non-HTTP caller can reuse the service without decoding its answer.
final case class AccountResponse(
id: String,
name: String,
balance: BigDecimal
)
object AccountResponse {
implicit val writes: OWrites[AccountResponse] = Json.writes
def from(account: AccountSummary): AccountResponse =
AccountResponse(
id = account.id.value,
name = account.name.value,
balance = account.balance
)
}
def render(result: Either[FindAccountError, AccountSummary]): Result =
result match {
case Right(account) => Ok(Json.toJson(AccountResponse.from(account)))
case Left(FindAccountError.NotFound(_)) => NotFound
case Left(FindAccountError.AccessDenied) => Forbidden
}Validate transport structure and domain meaning separately
Play Reads answers whether an incoming JSON document has the required transport shape and whether its values can be decoded. Domain construction answers whether those decoded values are meaningful for the operation. Combining the stages into Json.fromJson[DomainEntity] can allow an HTTP codec to become the only protection for domain invariants.
A request model can reflect client compatibility, optional fields and naming conventions. Translation then constructs validated values such as AccountName or PositiveAmount. Other entry points use the same constructors rather than duplicating the rules.
Avoid repeating every validation in both layers. Structural rules belong in the transport decoder; invariants that must hold regardless of the entry point belong with the domain type or command construction.
final case class OpenAccountRequest(
name: String,
openingBalance: BigDecimal
)
object OpenAccountRequest {
implicit val reads: Reads[OpenAccountRequest] = Json.reads
}
def toCommand(
request: OpenAccountRequest
): Either[OpenAccountError, OpenAccount] =
for {
name <- AccountName.from(request.name)
.left.map(OpenAccountError.InvalidName)
balance <- OpeningBalance.from(request.openingBalance)
.left.map(OpenAccountError.InvalidBalance)
} yield OpenAccount(name, balance)Type aliases can clarify or conceal
A type alias gives another name to an existing type. It can remove repetitive machinery and make a common abstraction easier to read. For example, Result[A] can establish that a group of functions shares Either[ApplicationError, A]. The type parameter and error channel remain visible enough to reason about.
An alias becomes a smell when it hides the only definition of the data shape. AccountData might expand to Map[String, Option[(String, BigDecimal)]]. Callers see a domain-sounding name, but the compiler still exposes map keys, tuple positions and optionality rather than named fields. Engineers must repeatedly navigate to the alias to interpret each operation.
Named case classes are usually better for records that cross a meaningful boundary. They give fields names, support focused constructors and make changes appear at affected call sites. Use aliases to name established type relationships, not to disguise an anonymous schema.
// Useful: the common error context remains clear.
type Result[A] = Either[ApplicationError, A]
// Opaque: every position and key needs external knowledge.
type AccountData = Map[String, Option[(String, BigDecimal)]]
// Explicit: the contract is visible where it is used.
final case class AccountData(
id: AccountId,
name: Option[AccountName],
balance: BigDecimal
)A Scala 2 alias does not create a distinct domain type
In Scala 2.13, type AccountId = String and type PaymentId = String remain the same underlying type. A PaymentId can be supplied to a method expecting AccountId because the aliases are interchangeable. The names improve local readability but do not enforce the distinction they appear to describe.
A value class can create a nominal distinction with little allocation overhead in many common uses. A private constructor and smart constructor can also validate the representation. There are restrictions and boxing cases around value classes, so the choice should follow the domain value’s use rather than an assumption that the wrapper is always free.
Scala 3 opaque types offer another way to expose a distinct type while using an underlying representation inside the defining scope. During a Scala 2 to Scala 3 migration, preserve construction and serialization behaviour rather than changing representation and compiler in one unexplained step.
final case class AccountId private (value: String) extends AnyVal
final case class PaymentId private (value: String) extends AnyVal
def loadAccount(id: AccountId): Future[Option[Account]] = ???
val paymentId: PaymentId = ???
// Does not compile:
// loadAccount(paymentId)Derived JSON is still an external contract
Json.writes and Json.format reduce codec boilerplate, but derivation does not make a representation internal. Field names and nested formats still determine the JSON clients receive. Renaming a case-class field or changing an implicit format can alter the API without changing the controller.
The risk increases when the same case class is used for HTTP, MongoDB and domain logic. A field added for persistence can leak into the API; an API rename can make old documents unreadable; an optional compatibility field can spread through business code where the value is actually required.
Separate models when their compatibility requirements diverge. If they genuinely share the same shape and lifecycle, reuse may be reasonable. Protect that decision with boundary tests using representative JSON and stored documents rather than relying only on case-class construction tests.
implicit val accountReads: Reads[AccountResponse] = (
(__ \ "id").read[String] and
((__ \ "displayName").read[String] orElse
(__ \ "name").read[String]) and
(__ \ "balance").read[BigDecimal]
)(AccountResponse.apply _)
implicit val accountWrites: OWrites[AccountResponse] =
Json.writes[AccountResponse]
// Compatibility is deliberate and testable.There are legitimate reasons to work in JSON
Returning JsValue is appropriate when JSON is the component’s actual responsibility. An HTTP presenter, a proxy that deliberately preserves an external payload, a JSON transformation service or code operating on schemaless stored documents may need to expose the representation directly.
The name and module should make that responsibility clear. AccountJsonPresenter returning JsObject communicates something different from AccountService returning ServiceResponse where the alias eventually resolves to JsValue.
The design question is not whether Json.toJson appears outside a controller file. It is whether the layer performing serialization owns the external representation, and whether callers that need application behaviour are forced to depend on that representation.
Review the boundary from its signature outward
A boundary is healthy when its signature provides enough information to use it correctly without reading several implementations and aliases. The types should distinguish meaningful inputs, expected failures and successful results. Transport and storage translation should have an identifiable owner.
Do not add layers merely to satisfy an architecture diagram. A small Play application may reasonably place response mapping beside the controller and reuse a domain case class for a stable internal endpoint. Add separation where invariants, consumers or change cycles differ.
The goal is traceability. An engineer should be able to follow a value from untrusted input, through validated application behaviour, to its public or persisted representation—and know which contract is changing at each step.
- Does the service signature describe the business result without opening its implementation?
- Does application code import Play JSON or Play Result without owning an HTTP concern?
- Can expected rejection be distinguished from operational failure?
- Does a type alias simplify a known abstraction or hide the data shape?
- Can two semantically different identifiers still be interchanged?
- Would changing a case-class field silently change JSON or MongoDB data?
- Could another adapter reuse the service without constructing or decoding JSON?