Play Framework #
Among Java web frameworks, Play occupies a very different position from both Spring Boot and Vaadin. Play was designed from the start with one conviction: a modern web server shouldn’t use the thread-per-request model. Instead of allocating one thread per incoming request and letting it block while waiting for a database or an external service, Play uses an asynchronous non-blocking model based on Akka. The result is a server that can handle tens of thousands of concurrent connections with a far smaller thread pool than the traditional model. Play is also known for its developer experience: hot reload that actually works, informative error messages right in the browser, and minimal configuration. For teams building high-performance REST APIs or highly I/O-bound services, Play is an alternative worth considering alongside the Spring ecosystem.
Play’s Non-Blocking Architecture #
The fundamental difference between Play and Spring MVC (or other servlet-based frameworks) is how they handle requests that take time — like database queries or calls to external APIs.
flowchart TD
subgraph BLOCKING[Spring MVC — Thread-per-Request]
R1[Request 1] --> T1["Thread 1\nblocked waiting for DB"]
R2[Request 2] --> T2["Thread 2\nblocked waiting for DB"]
R3[Request 3] --> T3["Thread 3\nblocked waiting for DB"]
R4[Request 4] --> WAIT["Queued...\nthreads exhausted"]
end
subgraph NONBLOCKING[Play — Non-Blocking]
R5[Request 1] --> EL["Event Loop\n4 threads"]
R6[Request 2] --> EL
R7[Request 3] --> EL
R8[Request 4] --> EL
EL -->|I/O done| RESP[Response sent]
endIn Play’s model, when an action needs to wait for a database result, the thread doesn’t block — it’s returned to the pool to handle other requests. When the database result is ready, a callback is scheduled to process the response. This is expressed in code through CompletionStage<Result> — the Java version of a Promise/Future.
sequenceDiagram
participant Client
participant Play as Play Server
participant DB as Database
Client->>Play: GET /products/1
Play->>DB: async query (thread free to serve other requests)
Note over Play: thread serves requests 2, 3, 4...
DB-->>Play: query result available
Play->>Play: callback executed
Play-->>Client: 200 OK JSONProject Setup #
Play uses sbt (Scala Build Tool) as its main build tool, even when you write Java code. This is one of the things that often surprises Java developers new to Play.
# Install sbt first
# On macOS
brew install sbt
# On Ubuntu/Debian
echo "deb https://repo.scala-sbt.org/scalasbt/debian all main" | sudo tee /etc/apt/sources.list.d/sbt.list
sudo apt-get update && sudo apt-get install sbt
# Create a new Play project from the Java template
sbt new playframework/play-java-seed.g8
# Follow the prompts — enter the project directory
cd project-name
sbt run # run with hot reload
The Play project directory structure:
project-name/
├── app/
│ ├── controllers/ ← HTTP action handlers
│ │ └── HomeController.java
│ ├── models/ ← data models and entities
│ └── views/ ← Twirl templates (HTML)
│ └── index.scala.html
├── conf/
│ ├── application.conf ← main configuration (HOCON format)
│ ├── routes ← routing definitions
│ └── logback.xml
├── public/ ← static assets
│ ├── css/
│ ├── js/
│ └── images/
├── test/ ← test files
└── build.sbt ← build configuration
The build.sbt file for a Java project:
name := "online-store"
organization := "com.example"
version := "1.0-SNAPSHOT"
lazy val root = (project in file(".")).enablePlugins(PlayJava)
scalaVersion := "2.13.14"
libraryDependencies ++= Seq(
// Play built-ins
guice, // dependency injection
javaJdbc, // JDBC support
evolutions, // database migration
// Database
"io.ebean" % "ebean" % "13.25.0",
"com.h2database" % "h2" % "2.2.224",
"org.postgresql" % "postgresql" % "42.7.3",
// Testing
"org.assertj" % "assertj-core" % "3.25.3" % Test
)
The conf/application.conf file (HOCON format — more expressive than properties):
# Application name
play.application.name = "Online Store"
play.http.secret.key = "changeme-replace-in-production-with-a-long-random-value"
# Database
db.default.driver = org.postgresql.Driver
db.default.url = "jdbc:postgresql://localhost:5432/onlinestore"
db.default.username = postgres
db.default.password = "secret"
# Connection pool
db.default.hikaricp.maximumPoolSize = 10
db.default.hikaricp.minimumIdle = 2
# Ebean ORM
ebean.default = ["models.*"]
# Evolutions (database migration)
play.evolutions.enabled = true
play.evolutions.autoApply = false # false in production — apply manually
# Logging
logger.root = ERROR
logger.play = INFO
logger.application = DEBUG
# Allowed hosts (security)
play.filters.hosts {
allowed = ["localhost", "example.com"]
}
Routing #
Play uses the conf/routes file to define all routes declaratively. Format: METHOD URL Controller.action.
# conf/routes
# Static pages
GET / controllers.HomeController.index()
# Product REST API
GET /api/products controllers.ProductController.getAllProducts()
POST /api/products controllers.ProductController.createProduct()
GET /api/products/:id controllers.ProductController.getProduct(id: Long)
PUT /api/products/:id controllers.ProductController.updateProduct(id: Long)
DELETE /api/products/:id controllers.ProductController.deleteProduct(id: Long)
# Query parameters — GET /api/products/search?keyword=laptop&page=2
GET /api/products/search controllers.ProductController.search(keyword: String, page: Int ?= 1)
# Path parameters with regex constraints
GET /api/products/$id<[0-9]+> controllers.ProductController.getProduct(id: Long)
# Static assets
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
# WebSocket
GET /ws/notifications controllers.WebSocketController.notifications()
Some important points about Play routing:
- Routes are evaluated from top to bottom — order matters if routes overlap.
- Parameters with
?=are optional parameters with default values. - Routing is verified at compile time — a typo in a controller or action name becomes a compile error.
- Routes can be used to generate URLs in code:
routes.ProductController.getProduct(42).
Controllers and Actions #
Controllers in Play are ordinary classes injected using Guice (Play’s built-in dependency injection). Each action is a method returning Result (sync) or CompletionStage<Result> (async).
Synchronous Actions #
package controllers;
import play.mvc.Controller;
import play.mvc.Http;
import play.mvc.Result;
import javax.inject.Inject;
import javax.inject.Singleton;
// @Singleton — one instance for all requests (stateless)
// DON'T store state in fields — there will be race conditions
@Singleton
public class HomeController extends Controller {
// Synchronous action — suitable for light operations that aren't I/O-bound
public Result index() {
return ok("Welcome to the Online Store API");
}
// Returning JSON
public Result status() {
com.fasterxml.jackson.databind.node.ObjectNode json =
play.libs.Json.newObject();
json.put("status", "ok");
json.put("timestamp", System.currentTimeMillis());
return ok(json);
}
// Accessing the request context
public Result info(Http.Request request) {
String userAgent = request.header("User-Agent").orElse("unknown");
String remoteAddress = request.remoteAddress();
return ok("UA: " + userAgent + " | IP: " + remoteAddress);
}
}
Asynchronous Actions — Non-Blocking #
package controllers;
import play.libs.concurrent.HttpExecutionContext;
import play.mvc.Controller;
import play.mvc.Http;
import play.mvc.Result;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
@Singleton
public class ProductController extends Controller {
private final ProductService productService;
private final HttpExecutionContext httpExecutionContext;
@Inject
public ProductController(ProductService productService,
HttpExecutionContext httpExecutionContext) {
this.productService = productService;
this.httpExecutionContext = httpExecutionContext;
}
// ✗ ANTI-PATTERN: blocking call in an action — blocks a Play thread
public Result getAllProductsBlocking() {
var products = productService.getAllProductsBlocking(); // blocks the thread!
return ok(play.libs.Json.toJson(products));
}
// ✓ CORRECT: async action — the thread doesn't block
public CompletionStage<Result> getAllProducts() {
return productService.getAllProductsAsync()
.thenApplyAsync(
products -> ok(play.libs.Json.toJson(products)),
httpExecutionContext.current() // run in the Play HTTP context
);
}
// GET /api/products/:id
public CompletionStage<Result> getProduct(Long id) {
return productService.findByIdAsync(id)
.thenApplyAsync(productOpt -> {
if (productOpt.isEmpty()) {
return notFound(errorJson("Product with ID " + id + " not found"));
}
return ok(play.libs.Json.toJson(productOpt.get()));
}, httpExecutionContext.current());
}
// POST /api/products
public CompletionStage<Result> createProduct(Http.Request request) {
com.fasterxml.jackson.databind.JsonNode body = request.body().asJson();
if (body == null) {
return CompletableFuture.completedFuture(
badRequest(errorJson("Request body must be JSON"))
);
}
// Manual validation from JSON
String name = body.path("name").asText();
double price = body.path("price").asDouble();
if (name.isBlank()) {
return CompletableFuture.completedFuture(
badRequest(errorJson("Product name must not be empty"))
);
}
return productService.saveAsync(name, price)
.thenApplyAsync(
product -> created(play.libs.Json.toJson(product)),
httpExecutionContext.current()
);
}
// DELETE /api/products/:id
public CompletionStage<Result> deleteProduct(Long id) {
return productService.deleteAsync(id)
.thenApplyAsync(success -> {
if (!success) {
return notFound(errorJson("Product not found"));
}
return noContent(); // 204 No Content
}, httpExecutionContext.current());
}
// Helper — build a consistent JSON error response
private com.fasterxml.jackson.databind.node.ObjectNode errorJson(String message) {
com.fasterxml.jackson.databind.node.ObjectNode error = play.libs.Json.newObject();
error.put("error", message);
error.put("timestamp", System.currentTimeMillis());
return error;
}
}
JSON Handling #
Play bundles Jackson and provides a convenient wrapper API through play.libs.Json.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import play.libs.Json;
public class JsonDemo {
// Convert a Java object to JSON
public static void objectToJson() {
Product product = new Product("Laptop", 15000000.0, 5);
// Automatically serializes public fields / getters
JsonNode json = Json.toJson(product);
System.out.println(json.toString());
// {"name":"Laptop","price":15000000.0,"stock":5}
}
// Convert JSON to a Java object
public static void jsonToObject(String jsonString) {
JsonNode json = Json.parse(jsonString);
Product product = Json.fromJson(json, Product.class);
}
// Build JSON programmatically
public static ObjectNode buildJsonResponse(boolean success, String message) {
ObjectNode root = Json.newObject();
root.put("success", success);
root.put("message", message);
root.put("timestamp", System.currentTimeMillis());
ArrayNode tags = root.putArray("tags");
tags.add("api");
tags.add("v1");
return root;
}
// Access values from JSON
public static void accessJson(JsonNode json) {
// path() — doesn't throw if the key is missing, returns a MissingNode
String name = json.path("name").asText("default");
double price = json.path("price").asDouble(0);
boolean active = json.path("active").asBoolean(true);
// get() — returns null if the key is missing
JsonNode stockNode = json.get("stock");
if (stockNode != null && !stockNode.isNull()) {
int stock = stockNode.asInt();
}
// Access arrays
JsonNode tags = json.path("tags");
if (tags.isArray()) {
for (JsonNode tag : tags) {
System.out.println("Tag: " + tag.asText());
}
}
}
}
Reads and Writes — Type-Safe JSON Mapping #
For more robust mapping with validation, define Reads and Writes:
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
// DTO with Jackson annotations for serialization control
@JsonIgnoreProperties(ignoreUnknown = true) // ignore unknown fields
public class ProductRequest {
@JsonProperty("name")
private final String name;
@JsonProperty("price")
private final double price;
@JsonProperty("stock")
private final int stock;
// @JsonCreator marks this constructor for deserialization
@JsonCreator
public ProductRequest(
@JsonProperty("name") String name,
@JsonProperty("price") double price,
@JsonProperty("stock") int stock) {
this.name = name;
this.price = price;
this.stock = stock;
}
public String getName() { return name; }
public double getPrice() { return price; }
public int getStock() { return stock; }
}
// Usage in a controller
public CompletionStage<Result> createProductTypeSafe(Http.Request request) {
// fromJson automatically uses the Jackson annotations
ProductRequest productReq = Json.fromJson(
request.body().asJson(), ProductRequest.class
);
if (productReq.getName() == null || productReq.getName().isBlank()) {
return CompletableFuture.completedFuture(
badRequest(errorJson("name is required"))
);
}
return productService.saveAsync(
productReq.getName(), productReq.getPrice())
.thenApplyAsync(
p -> created(Json.toJson(p)),
httpExecutionContext.current()
);
}
Form Validation #
Play provides the Form API for validating form requests (both JSON and form-encoded):
import play.data.Form;
import play.data.FormFactory;
import play.data.validation.Constraints;
// Data class for form binding
public class ProductForm {
@Constraints.Required(message = "Product name is required")
@Constraints.MinLength(value = 3, message = "Name must be at least 3 characters")
@Constraints.MaxLength(value = 100, message = "Name must be at most 100 characters")
public String name;
@Constraints.Required(message = "Price is required")
@Constraints.Min(value = 1, message = "Price must be at least 1")
public Double price;
@Constraints.Min(value = 0, message = "Stock must not be negative")
public Integer stock = 0;
}
// Controller with FormFactory
@Singleton
public class ProductFormController extends Controller {
private final FormFactory formFactory;
private final ProductService productService;
private final HttpExecutionContext httpExecutionContext;
@Inject
public ProductFormController(FormFactory formFactory,
ProductService productService,
HttpExecutionContext httpExecutionContext) {
this.formFactory = formFactory;
this.productService = productService;
this.httpExecutionContext = httpExecutionContext;
}
public CompletionStage<Result> createProduct(Http.Request request) {
Form<ProductForm> form = formFactory.form(ProductForm.class)
.bindFromRequest(request); // bind from the JSON body or form-encoded
if (form.hasErrors()) {
// Return all errors in JSON format
return CompletableFuture.completedFuture(
badRequest(form.errorsAsJson())
);
}
ProductForm data = form.get();
return productService.saveAsync(data.name, data.price)
.thenApplyAsync(
product -> created(Json.toJson(product)),
httpExecutionContext.current()
);
}
}
Filters and Middleware #
Filters in Play are the mechanism for applying logic that runs for every request — similar to middleware in other frameworks. Suitable for logging, authentication, CORS, and rate limiting.
import play.mvc.EssentialAction;
import play.mvc.EssentialFilter;
import play.mvc.Http;
import javax.inject.Inject;
import java.util.concurrent.Executor;
// Filter for logging every request
public class LoggingFilter extends EssentialFilter {
private final Executor executor;
@Inject
public LoggingFilter(Executor executor) {
this.executor = executor;
}
@Override
public EssentialAction apply(EssentialAction next) {
return EssentialAction.of(request -> {
long start = System.currentTimeMillis();
return next.apply(request).map(result -> {
long duration = System.currentTimeMillis() - start;
System.out.printf(
"[%s] %s %s — %d (%dms)%n",
java.time.LocalDateTime.now(),
request.method(),
request.uri(),
result.status(),
duration
);
return result;
}, executor);
});
}
}
// Filter for Bearer token authentication
public class AuthFilter extends EssentialFilter {
private final Executor executor;
private static final String TOKEN_VALID = "secret-token-production";
@Inject
public AuthFilter(Executor executor) {
this.executor = executor;
}
@Override
public EssentialAction apply(EssentialAction next) {
return EssentialAction.of(request -> {
// Skip the filter for public routes
if (isPublicRoute(request)) {
return next.apply(request);
}
// Check the Authorization header
String authHeader = request.header(Http.HeaderNames.AUTHORIZATION)
.orElse("");
if (!authHeader.startsWith("Bearer ") ||
!authHeader.substring(7).equals(TOKEN_VALID)) {
return akka.stream.javadsl.Source.single(
play.mvc.Results.unauthorized(
Json.toJson(java.util.Map.of("error", "Invalid token"))
).body()
);
// More idiomatic — return a Result directly:
}
return next.apply(request);
});
}
private boolean isPublicRoute(Http.RequestHeader request) {
String path = request.path();
return path.equals("/") || path.startsWith("/public");
}
}
Register filters in conf/application.conf:
play.filters.enabled += "filters.LoggingFilter"
play.filters.enabled += "filters.AuthFilter"
# Useful built-in Play filters
play.filters.enabled += "play.filters.cors.CORSFilter"
play.filters.enabled += "play.filters.gzip.GzipFilter"
play.filters.enabled += "play.filters.csrf.CSRFFilter"
# CORS configuration
play.filters.cors {
allowedOrigins = ["https://frontend.example.com", "http://localhost:3000"]
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Content-Type", "Authorization"]
}
Database Access with Ebean #
Ebean is the recommended ORM for Play Java. It’s lighter than Hibernate and has a simpler API.
package models;
import io.ebean.Model;
import io.ebean.annotation.WhenCreated;
import io.ebean.annotation.WhenModified;
import javax.persistence.*;
import java.time.Instant;
import java.math.BigDecimal;
// Ebean entity
@Entity
@Table(name = "products")
public class Product extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;
@Column(nullable = false, length = 100)
public String name;
@Column(nullable = false, precision = 15, scale = 2)
public BigDecimal price;
@Column(nullable = false)
public int stock;
@Column(nullable = false)
public boolean active = true;
@WhenCreated
public Instant createdAt;
@WhenModified
public Instant updatedAt;
// Finder — query builder for this model
public static final Finder<Long, Product> find =
new Finder<>(Product.class);
}
package services;
import io.ebean.DB;
import models.Product;
import javax.inject.Singleton;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
@Singleton
public class ProductService {
// Run database queries on a separate thread so they don't block the event loop
private static final java.util.concurrent.Executor dbExecutor =
java.util.concurrent.Executors.newFixedThreadPool(10);
// Get all products (async)
public CompletionStage<List<Product>> getAllProductsAsync() {
return CompletableFuture.supplyAsync(() ->
Product.find.query()
.where()
.eq("active", true)
.orderBy("name asc")
.findList(),
dbExecutor
);
}
// Find by ID
public CompletionStage<Optional<Product>> findByIdAsync(Long id) {
return CompletableFuture.supplyAsync(() ->
Optional.ofNullable(Product.find.byId(id)),
dbExecutor
);
}
// Find by name (partial match)
public CompletionStage<List<Product>> searchByNameAsync(String keyword) {
return CompletableFuture.supplyAsync(() ->
Product.find.query()
.where()
.ilike("name", "%" + keyword + "%")
.findList(),
dbExecutor
);
}
// Save a new product
public CompletionStage<Product> saveAsync(String name, double price) {
return CompletableFuture.supplyAsync(() -> {
Product product = new Product();
product.name = name;
product.price = BigDecimal.valueOf(price);
product.stock = 0;
product.save(); // Ebean INSERT
return product;
}, dbExecutor);
}
// Update a product
public CompletionStage<Optional<Product>> updateAsync(Long id, String name, double price) {
return CompletableFuture.supplyAsync(() -> {
Product product = Product.find.byId(id);
if (product == null) return Optional.empty();
product.name = name;
product.price = BigDecimal.valueOf(price);
product.update(); // Ebean UPDATE
return Optional.of(product);
}, dbExecutor);
}
// Delete a product (soft delete)
public CompletionStage<Boolean> deleteAsync(Long id) {
return CompletableFuture.supplyAsync(() -> {
Product product = Product.find.byId(id);
if (product == null) return false;
product.active = false;
product.update();
return true;
}, dbExecutor);
}
// Query with raw SQL using a named query
public CompletionStage<List<Product>> lowStockProducts(int minStock) {
return CompletableFuture.supplyAsync(() ->
DB.find(Product.class)
.setRawSql(io.ebean.RawSqlBuilder
.parse("SELECT id, name, stock FROM products WHERE stock <= :limit AND active = true")
.create())
.setParameter("limit", minStock)
.findList(),
dbExecutor
);
}
}
Database Evolutions #
Play uses Evolutions to manage database schemas. SQL files are stored in conf/evolutions/default/:
-- conf/evolutions/default/1.sql
-- !Ups (runs on apply)
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
price NUMERIC(15, 2) NOT NULL,
stock INTEGER NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_products_name ON products (name);
CREATE INDEX idx_products_active ON products (active);
-- !Downs (runs on revert)
DROP TABLE IF EXISTS products;
-- conf/evolutions/default/2.sql
-- !Ups
ALTER TABLE products ADD COLUMN category VARCHAR(50);
CREATE INDEX idx_products_category ON products (category);
-- !Downs
ALTER TABLE products DROP COLUMN category;
WebSockets #
Play supports WebSockets natively. This is one of Play’s advantages over older Spring MVC versions — WebSocket is a first-class citizen because of the Akka Streams architecture underneath.
import akka.stream.javadsl.Flow;
import play.mvc.WebSocket;
import javax.inject.Inject;
import javax.inject.Singleton;
@Singleton
public class WebSocketController extends Controller {
// Simple WebSocket echo — send back what was received
public WebSocket echo() {
return WebSocket.Text.accept(request ->
Flow.<String>create() // receive String messages
.map(message -> "Echo: " + message) // process
// return to the client
);
}
// WebSocket with more complex logic
public WebSocket notifications() {
return WebSocket.Text.accept(request -> {
// Get the user ID from a query parameter
String userId = request.queryString("userId")
.map(values -> values.get(0))
.orElse("anonymous");
System.out.println("WebSocket connected: user=" + userId);
return Flow.<String>create()
.map(message -> {
System.out.println("Message from " + userId + ": " + message);
// Process the message and send a response
return "Server received: " + message + " (from user: " + userId + ")";
});
});
}
}
Testing #
Play provides the play.test API for running tests with or without a running server.
import org.junit.Test;
import play.Application;
import play.inject.guice.GuiceApplicationBuilder;
import play.mvc.Http;
import play.mvc.Result;
import play.test.Helpers;
import play.test.WithApplication;
import static org.assertj.core.api.Assertions.assertThat;
import static play.test.Helpers.*;
import static play.mvc.Http.Status.*;
public class ProductControllerTest extends WithApplication {
// WithApplication provides an Application instance for each test
@Override
protected Application provideApplication() {
return new GuiceApplicationBuilder()
.configure("db.default.url", "jdbc:h2:mem:test;MODE=PostgreSQL")
.configure("db.default.driver", "org.h2.Driver")
.configure("play.evolutions.autoApply", "true")
.build();
}
@Test
public void getAllProducts_withoutData_returnsEmptyArray() {
Http.RequestBuilder request = new Http.RequestBuilder()
.method(GET)
.uri("/api/products");
Result result = route(app, request);
assertThat(result.status()).isEqualTo(OK);
assertThat(contentAsString(result)).contains("[]");
}
@Test
public void createProduct_withValidData_returns201() {
String body = """
{
"name": "Gaming Laptop",
"price": 15000000,
"stock": 5
}
""";
Http.RequestBuilder request = new Http.RequestBuilder()
.method(POST)
.uri("/api/products")
.header("Content-Type", "application/json")
.bodyText(body);
Result result = route(app, request);
assertThat(result.status()).isEqualTo(CREATED);
com.fasterxml.jackson.databind.JsonNode json =
play.libs.Json.parse(contentAsString(result));
assertThat(json.path("name").asText()).isEqualTo("Gaming Laptop");
assertThat(json.path("id").asLong()).isGreaterThan(0);
}
@Test
public void getProduct_nonexistentId_returns404() {
Http.RequestBuilder request = new Http.RequestBuilder()
.method(GET)
.uri("/api/products/999");
Result result = route(app, request);
assertThat(result.status()).isEqualTo(NOT_FOUND);
}
}
When to Use Play and When Not To #
USE PLAY FRAMEWORK WHEN:
✓ Building REST APIs or highly I/O-bound services
✓ You need high throughput with efficient thread resources
✓ The team is familiar with async/reactive programming models
✓ You need fast hot reload in development
✓ WebSockets and streaming are primary requirements
✓ You don't need the Spring ecosystem (security, batch, etc.)
✓ You want an architecture lighter than Spring Boot
CONSIDER ALTERNATIVES WHEN:
✗ The team is more familiar with Spring — the async learning curve isn't worth it
✗ You need a complete enterprise ecosystem → Spring Boot is far richer
✗ The application is mostly CPU-bound, not I/O-bound → async doesn't help much
✗ The Java team isn't comfortable with sbt as the build tool
✗ You need mature JPA/Hibernate integration → Spring Data is more complete
✗ Ecosystem community and documentation → Spring is far larger
flowchart TD
A{"High throughput\nI/O-bound?"} -- Yes --> B{"Team familiar\nwith async?"}
A -- No --> SPRING[Spring Boot]
B -- Yes --> C{"Need a complete\nenterprise ecosystem?"}
B -- No --> D{"Willing to learn\nthe async model?"}
C -- Yes --> SPRING
C -- No --> PLAY[Play Framework]
D -- Yes --> PLAY
D -- No --> SPRINGSummary #
- Play is a non-blocking framework — actions that need I/O must return
CompletionStage<Result>, not aResultdirectly. Don’t run blocking operations (synchronous database queries, Thread.sleep) on Play’s event loop threads.- The
conf/routesfile is the single point of routing definition — all URL-to-controller-action mappings live there, verified at compile time so typos are caught immediately.httpExecutionContext.current()must be used as the executor in everythenApplyAsync()to ensure callbacks run in the correct Play HTTP context, not Java’s default fork-join pool.- Run database queries on a separate thread pool (
dbExecutor) usingCompletableFuture.supplyAsync()— Ebean isn’t natively non-blocking, so it needs to execute outside the event loop.- Filters are Play’s idiomatic way of doing middleware — logging, authentication, CORS, and rate limiting are all implemented as
EssentialFilters and registered inapplication.conf.- Evolutions manage database schema migrations — one SQL file per version with
!Upsand!Downsblocks. UseautoApply=falsein production and apply manually.conf/application.confuses the HOCON format, which is more expressive than properties — it supports inheritance, variable substitution, and better comments.- Play fits best for I/O-bound REST APIs with high throughput — if the team is more familiar with the Spring ecosystem, Play’s benefits don’t outweigh its learning curve.