scala Tutorial

Building an Expense Tracker with Scala 3 - Part 2: Database & Docker

In Part 1, we set up our Scala 3 project. Now, we will configure our database infrastructure using Docker Compose and connect our application to MySQL.

A robust backend needs a reliable place to store data. We chose MySQL for its ubiquity and reliability, and Docker Compose to make our development environment reproducible. No more "it works on my machine" issues!

Docker Compose Setup

We'll use Docker Compose to spin up a MySQL instance easily. This saves us from installing MySQL directly on our host machine and keeps our project self-contained.

Create a docker-compose.yml file in the project root:

version: '3.8'

services:
  db:
    image: mysql:8.0
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: expense_tracker
      MYSQL_USER: user
      MYSQL_PASSWORD: password
    ports:
      - "3306:3306"
    volumes:
      - db_data:/var/lib/mysql

volumes:
  db_data:

Key points:

  • mysql:8.0: We're using a specific version to ensure consistency.
  • volumes: We map a volume db_data to persist our data even if we destroy the container.
  • ports: We expose port 3306 so our Play application (running on the host) can talk to the database.

To start the database, run:

docker-compose up -d

Configuring Play Framework

Now we need to configure Play to connect to this database. Play uses a configuration file conf/application.conf based on HOCON (Human-Optimized Config Object Notation).

First, ensure you have the MySQL driver in your build.sbt (we added this in Part 1).

Next, update conf/application.conf with the database credentials:

# Default database configuration using MySQL database engine
db.default.driver = "com.mysql.cj.jdbc.Driver"
db.default.url = "jdbc:mysql://localhost:3306/expense_tracker?useSSL=false"
db.default.username = "user"
db.default.password = "password"

# Connection pool settings (optional but recommended for production)
play.db.pool = "hikari"
play.db.prototype.hikaricp.minimumIdle = 2
play.db.prototype.hikaricp.maximumPoolSize = 10

Database Service

We'll create a simple DatabaseService to manage our connections. While Play provides a DB API, wrapping it in a service allows us to abstract away the specific implementation details and makes testing easier.

Since we are using raw JDBC (as requested for this tutorial to understand the basics), we'll create a helper to execute blocks of code with a connection.

// app/services/DatabaseService.scala
package services

import java.sql.{Connection, DriverManager}
import javax.inject.{Inject, Singleton}
import play.api.db.Database

@Singleton
class DatabaseService(db: Database) {
  // This helper function manages the lifecycle of the connection.
  // It borrows a connection from the pool, gives it to your block of code,
  // and ensures it's closed (returned to the pool) afterwards.
  def withConnection[A](block: Connection => A): A = {
    db.withConnection { conn =>
      block(conn)
    }
  }
}

Why withConnection? Manually opening and closing connections is error-prone. If you forget to close a connection, you'll leak resources and eventually crash your database. Play's db.withConnection handles this automatically, even if an exception is thrown.

Wiring the Service

Finally, we need to wire this service into our application using Macwire in AppLoader.scala. This makes the DatabaseService available for injection into our controllers.

// app/AppLoader.scala
// ... imports

class AppComponents(context: Context) extends BuiltInComponentsFromContext(context)
  with HttpFiltersComponents
  with AssetsComponents {

  lazy val homeController: HomeController = wire[HomeController]
  
  // We wire the DatabaseService here. Macwire finds the `Database` dependency
  // from `BuiltInComponentsFromContext` (which includes DB components)
  // and injects it into our DatabaseService constructor.
  lazy val databaseService: services.DatabaseService = wire[services.DatabaseService]

  // ...
}

Testing the Connection

You can now inject DatabaseService into your controllers to perform database operations.

In the next part, we will implement Authentication to secure our application.

Continue to Part 3: Authentication