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]
    end

In 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 JSON

Project 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 --> SPRING

Summary #

  • Play is a non-blocking framework — actions that need I/O must return CompletionStage<Result>, not a Result directly. Don’t run blocking operations (synchronous database queries, Thread.sleep) on Play’s event loop threads.
  • The conf/routes file 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 every thenApplyAsync() 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) using CompletableFuture.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 in application.conf.
  • Evolutions manage database schema migrations — one SQL file per version with !Ups and !Downs blocks. Use autoApply=false in production and apply manually.
  • conf/application.conf uses 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.

← Previous: Vaadin   Next: Quarkus →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact