Modelling a Play Framework application beyond controllers and JSON
A practical route from untrusted HTTP input to validated domain commands, explicit service outcomes and stable Play JSON responses.
HTTP models and domain models have different jobs
A Play controller sits at an untrusted boundary. Request bodies may be missing fields, contain malformed values or use representations chosen for compatibility with clients. A domain model should represent the states the application is prepared to reason about after those checks have succeeded.
Using one case class for JSON, business logic and persistence is initially convenient, but it couples three reasons to change. A renamed API field can disturb stored documents; an optional transport field can introduce Option deep into code where the value is actually required.
Small applications do not need ceremonial layers. Separate the models when the boundary has different invariants, lifecycle or compatibility requirements—not simply because a diagram says every service needs a DTO.
final case class CreateAccountRequest(name: String, openingBalance: BigDecimal)
object CreateAccountRequest {
implicit val reads: Reads[CreateAccountRequest] = Json.reads
}
final case class AccountName private (value: String)
object AccountName {
def from(value: String): Either[String, AccountName] =
Either.cond(value.trim.nonEmpty, AccountName(value.trim), "name is required")
}
final case class OpenAccount(name: AccountName, openingBalance: BigDecimal)Validate structure first, then domain meaning
Play JSON Reads answers structural questions: is the body JSON, are required fields present, and can their values be decoded? Domain construction answers a different set of questions: is this name usable and is the opening balance permitted?
Keeping both stages visible improves error handling. JsError can become a consistent 400 response describing malformed input. A domain rejection can use the application’s own error vocabulary without importing Play JSON types into the service layer.
Validation should not be duplicated indiscriminately. The domain constructor is the authoritative place for an invariant that must hold regardless of whether input arrives through HTTP, a test or another service entry point.
def toCommand(request: CreateAccountRequest): Either[CreateError, OpenAccount] =
for {
name <- AccountName.from(request.name).left.map(CreateError.InvalidName)
_ <- Either.cond(
request.openingBalance >= 0,
(),
CreateError.InvalidBalance("opening balance cannot be negative")
)
} yield OpenAccount(name, request.openingBalance)
def create: Action[JsValue] = Action.async(parse.json) { request =>
request.body.validate[CreateAccountRequest].fold(
errors => Future.successful(BadRequest(JsError.toJson(errors))),
input => handle(input)
)
}Make service outcomes explicit
A service returning Future[Result] is easy to wire but gives the domain layer control over HTTP and makes its behaviour harder to reuse. Prefer a service result that describes what happened, then translate that result into Play’s Result at the controller boundary.
Expected outcomes such as duplicate accounts or rejected commands are values, not failed Futures. Reserve a failed Future for failures that prevented the operation from producing a domain answer: a database outage, timeout or unexpected defect.
This separation also improves tests. Service tests assert domain outcomes without building fake requests. Controller tests concentrate on JSON, status codes and response bodies rather than repeating the business rules.
sealed trait CreateAccountResult
object CreateAccountResult {
final case class Created(account: Account) extends CreateAccountResult
case object AlreadyExists extends CreateAccountResult
}
def render(result: CreateAccountResult): Result = result match {
case CreateAccountResult.Created(account) =>
Created(Json.toJson(AccountResponse.from(account)))
case CreateAccountResult.AlreadyExists =>
Conflict(Json.obj("code" -> "account_exists"))
}
service.create(command).map(render)Dependency injection belongs at construction boundaries
Play’s dependency injection can construct controllers, services and repositories, but injected classes should still expose small dependencies with meaningful interfaces. Injecting a large application environment into every class hides what each operation needs.
Constructor injection makes dependencies visible and permits ordinary unit tests. It does not require an interface for every concrete class. Introduce a trait when there is a real boundary—persistence, an external API, time, or another effect that tests and implementations need to vary.
Keep configuration parsing and resource creation near application startup. A repository should receive the collection or client it needs rather than repeatedly looking it up from global configuration. Invalid required configuration should fail during startup, not on the first production request.
trait AccountRepository {
def insert(account: Account): Future[InsertResult]
def findByName(name: AccountName): Future[Option[Account]]
}
final class AccountService @Inject()(
repository: AccountRepository,
clock: java.time.Clock
)(implicit ec: ExecutionContext) {
def create(command: OpenAccount): Future[CreateAccountResult] = ???
}
final class AccountController @Inject()(
components: ControllerComponents,
service: AccountService
)(implicit ec: ExecutionContext) extends AbstractController(components)Persistence and JSON changes need compatibility decisions
Case-class shape is not automatically an API or database migration strategy. Play JSON derivation follows field names and available formats; MongoDB mappings have their own representation. Renaming a Scala field can therefore change external data even when business behaviour is unchanged.
Use explicit reads when accepting an old and new representation during a transition. Write only the intended current format, measure remaining legacy data, and remove compatibility code deliberately. For stored documents, decide whether migration happens on read, through a background process or before deployment.
Integration tests should cover the real boundary: representative JSON through the route, repository behaviour against MongoDB, and the response or stored document clients rely upon. Unit tests alone cannot prove that formats, wiring and indexes agree.
- Version externally visible changes intentionally
- Test malformed, missing and additional input fields
- Keep domain values independent of Play Result and JsValue
- Exercise repository mappings against the real database
- Record whether old payloads and documents remain readable