Daniel Hangaard cph-dh258@stud.ek.dk github.com/DHangaard
This project is a backend API for a Table Top Roleplaying Game (TTRPG) companion application, Sheet Herder. The system allows players to create and manage their characters and personal notes, while game masters can organize campaigns, track session progress, and keep an eye on their party — all in one place.
Portfolio website: dhangaard.dk
Project overview video: video demo
Deployed application: sheet-herder-api.dhangaard.dk
Source code repository: github.com/DHangaard/sheet-herder
Sheet Herder is built as a layered backend architecture with clear separation between concerns:
- Controller layer — handles HTTP endpoints and request validation
- Service layer — owns business logic and domain rules
- DAO layer — manages database access via JPQL
- Entity layer — JPA-mapped domain models
Technologies used:
- Java 17
- Javalin 7
- JPA / Hibernate 7 + HikariCP
- PostgreSQL
- JWT authentication (Nimbus JOSE+JWT)
- Maven (shade plugin →
app.jar) - Docker + Caddy (reverse proxy, automatic TLS)
- DigitalOcean
- JUnit Jupiter, REST Assured, Testcontainers (testing)
- D&D 5e REST API (external reference data)
The project is organized into distinct layers — controllers handle HTTP and request validation, services own business logic, DAOs handle database access, and entities model the domain. Dependencies only flow downward; no layer reaches up.
All controllers, services, and DAOs are defined behind interfaces. This follows the Dependency Inversion principle — components depend on abstractions, not concrete implementations. It also enforces a clear contract for each layer and keeps them independently testable.
All DTOs are implemented as Java records — immutable by design, with no boilerplate. A DTO that carries data in one direction has no business being mutable.
Passwords are hashed using BCrypt with a cost factor of 12. Before hashing, the plain password is prehashed with SHA-256 and Base64-encoded — this removes BCrypt's 72-byte input limit and ensures full password entropy is preserved regardless of length.
Authentication is implemented using JWT tokens via a custom wrapper around Nimbus JOSE+JWT. On login, a signed token is issued and must be included in all subsequent requests. As a fail-fast measure, missing JWT_SECRET or JWT_ISSUER environment variables crash the application immediately on startup — a missing configuration should never surface as a cryptic runtime error.
Authorization is enforced through Javalin before-filters. Public endpoints are marked explicitly with Role.ANYONE — making access intent visible in the route definition rather than relying on the absence of a role check.
All domain exceptions extend ApiException, which carries an HTTP status code. ApiException is never thrown directly — only its subtypes are. This means every exception type is explicit about its intent.
Two handlers cover all cases. Known exceptions are caught as ApiException, logged at WARN, and returned as a controlled {"status": ..., "message": ...} shape. Everything else is caught as Exception, logged at ERROR with the full stack trace, and always returns a generic 500 — internal details never reach the client.
The request logger follows the same logic: 5xx logs at ERROR, 4xx at WARN, everything else at INFO. The health check endpoint actively probes the database with a SELECT 1 query and returns 503 Service Unavailable if the connection fails — giving the load balancer an accurate picture of application health.
Represents a registered user in the system.
Fields:
id— auto-generated primary keyusername— unique, required, trimmed on persist and updateemail— unique, required, lowercased and trimmed on persist and updatehashedPassword— BCrypt-hashed with SHA-256 prehashroles— set ofRoleenums, defaults toUSERon creationcharacterSheets— owned character sheets; deleting a user cascades to all of them
Represents a D&D 5e character owned by a user. A user can own multiple character sheets; names are unique per user.
Fields:
id— auto-generated primary keyname— character name, trimmed and validated on persist and updaterace— reference toRacesubrace— reference toSubracelanguages— many-to-many reference toLanguageabilityScores— map ofAbilityenum to integer scorenotes— map of title to note textuser— owning user (FK)
Modelled but not yet implemented. Campaign represents a game master's campaign, and CampaignMembership links users and their characters to it with a role — reflecting that the same user can be a player in one campaign and a game master in another.
Race, Subrace, Trait, and Language are populated from the D&D 5e REST API at startup and persisted locally. Each entity carries a SHA-256 content hash — on every startup, incoming records are compared against stored ones and only changed or new records are written.
All errors follow this format:
{ "status": 404, "message": "Explains the problem" }| Method | URL | Request Body | Response | Status |
|---|---|---|---|---|
| POST | /api/v1/auth/register |
RegisterRequestDTO |
LoginResponseDTO |
201 / 409 |
| POST | /api/v1/auth/login |
LoginRequestDTO |
LoginResponseDTO |
200 / 401 |
{ "email": "String", "username": "String", "password": "String" }{ "email": "String", "password": "String" }{ "token": "String (JWT)" }Requires: Authorization: Bearer <token>
| Method | URL | Request Body | Response | Status |
|---|---|---|---|---|
| GET | /api/v1/character-sheets |
[CharacterSheetDTO] |
200 | |
| GET | /api/v1/character-sheets/{id} |
CharacterSheetDTO |
200 / 403 / 404 | |
| POST | /api/v1/character-sheets |
CreateCharacterSheetDTO |
CharacterSheetDTO |
201 / 400 / 409 |
| PUT | /api/v1/character-sheets/{id} |
UpdateCharacterSheetDTO |
CharacterSheetDTO |
200 / 400 / 403 / 404 |
| DELETE | /api/v1/character-sheets/{id} |
204 / 403 / 404 |
{
"name": "String",
"raceId": "Long",
"subraceId": "Long",
"languageIds": "[Long]",
"abilityScores": "{ Ability: Integer }"
}{
"name": "String",
"raceId": "Long",
"subraceId": "Long",
"languageIds": "[Long]",
"abilityScores": "{ Ability: Integer }",
"notes": "{ String: String }"
}{
"id": "Long",
"userId": "Long",
"name": "String",
"raceId": "Long",
"raceName": "String",
"subraceId": "Long",
"subraceName": "String",
"languages": "[LanguageDTO]",
"abilityScores": "{ Ability: Integer }",
"notes": "{ String: String }",
"createdAt": "DateTime",
"updatedAt": "DateTime"
}The API also exposes reference data endpoints for races, subraces, traits, and languages, as well as user management and a health check endpoint.
The following user stories represent the implemented scope of the project. They cover the core functionality that the system is built around — authentication, character management, and the reference data that supports it. All user stories including full acceptance criteria can be found in user stories.
As a user, I want to register and log in, so that I can access and manage my characters.
As a player, I want to create, edit, and delete characters, so that I can manage my tabletop characters digitally.
As a player, I want to see an overview of all my characters, so that I can easily manage and access them.
As a player, I want to add and edit notes on my character, so that I can keep track of story events and development.
As a user, I want access to predefined reference data during character creation, so that I can create characters consistently.
Early in the project, a significant amount of time was spent on analysis before writing any code. The goal was to ensure that the domain model reflected the actual problem being solved — not the technology used to solve it. Entities were kept lean and named after concepts that made sense in the context of a TTRPG companion, rather than after database tables or framework conventions. This approach made later implementation decisions easier to reason about, because the model had a clear real-world anchor.
Designing the entity relationships was one of the more time-consuming parts of the project. The default approach was to keep associations unidirectional — a deliberate choice to reduce coupling between entities and limit how much each entity needs to know about the rest of the model. In practice, this required careful thought about ownership and how data would flow through the system.
Reference data entities — races, subraces, traits, and languages — ended up using FetchType.EAGER across the board. This was a conscious trade-off: given that reference data is always needed when mapping a character sheet, lazy loading would only add complexity without meaningful benefit at the scale this application operates at.
Tests were not written to achieve coverage numbers — they were written to verify that the system behaves correctly according to its specification. The process started with the acceptance criteria and business rules, which were used to derive concrete test cases. Some tests map directly to a specific user story or business rule. Integration tests run against a real PostgreSQL instance via Testcontainers, meaning the tests reflect actual database behavior rather than mocked substitutes.
Logback is configured with three appenders: console output for development, a rolling application log (daily rotation, 30-day retention), and a dedicated error log (weekly rotation, 13-week retention). Log level is controlled via the LOG_LEVEL environment variable, making it configurable per environment without touching code. Third-party frameworks are capped at WARN to suppress noise from Hibernate, Jetty, HikariCP, and Testcontainers. The one exception is HikariDataSource, which logs at INFO to confirm connection pool startup. Startup time and reference data fetch duration are tracked using a custom ExecutionTimer utility, which logs elapsed time at key points in the startup sequence.
Every push to main triggers a GitHub Actions pipeline that builds the project, runs the full test suite, and pushes a new Docker image to Docker Hub. After a successful push, the pipeline triggers a Watchtower webhook via a restricted SSH key, which instructs the running container to pull and redeploy the new image automatically. Watchtower's default polling interval of 60 seconds was sufficient for most cases, but implementing the webhook was a deliberate choice — an opportunity to work with a deployment mechanism I had not used before, with the added benefit of near-instant redeployment on every passing build.
The source code in this repository is licensed under the MIT License. See the LICENSE file for details.
Note: The project name, logo, and visual identity are not included under the MIT License. Please refer to BRANDING.md for usage guidelines and copyright information regarding these assets.

