A REST API for creating, managing, and publishing articles. The project is built as a learning-focused Spring Boot application that demonstrates Clean Architecture, JWT authentication, role-based authorization, PostgreSQL persistence, database migrations, validation, and automated architecture tests.
- User registration with BCrypt password hashing
- JWT login using HMAC SHA-256
- Role-based access for readers, authors, editors, and administrators
- Article draft, publish, and archive lifecycle
- Ownership rules for updating, archiving, and deleting articles
- Public paginated listing of published articles
- Optional article filtering by category
- PostgreSQL persistence and Flyway migrations
- Consistent JSON error responses
- Request logging with status, authenticated user, and duration
- OpenAPI documentation and Swagger UI
- Unit, web, context, and ArchUnit architecture tests
- Java 17
- Spring Boot 4.1
- Spring Web MVC
- Spring Security and OAuth2 Resource Server
- Spring Data JPA and Hibernate
- PostgreSQL 16
- Flyway
- Gradle
- springdoc-openapi
- JUnit 5, Mockito, MockMvc, and ArchUnit
The application uses feature-oriented Clean/Hexagonal Architecture. Each feature is divided into domain, application, adapter, and configuration packages.
com.tomoka.articleservice
├── article
│ ├── domain
│ ├── application
│ │ ├── command
│ │ ├── model
│ │ └── port
│ │ ├── in
│ │ └── out
│ ├── adapter
│ │ ├── in
│ │ │ ├── transaction
│ │ │ └── web
│ │ └── out
│ │ └── persistence
│ └── config
├── auth
│ ├── application
│ ├── adapter
│ └── config
├── user
│ ├── domain
│ ├── application
│ ├── adapter
│ └── config
├── config
├── exception
└── filter
Dependencies point inward:
Web / Security / Persistence adapters
↓
Application ports
↓
Application services
↓
Domain
The domain and application packages contain no Spring, Jakarta, servlet, JPA, or JWT dependencies. Spring-specific transaction decorators surround the use cases, while persistence and security integrations implement outbound application ports.
ArchUnit tests enforce these boundaries during every test run.
- Java 17
- Docker with Docker Compose
The project includes the Gradle wrapper, so a separate Gradle installation is not required.
Create your environment file:
cp .env.example .envEdit .env:
DB_PASSWORD=article_password
JWT_SECRET=replace-with-at-least-32-random-charactersYou can generate a JWT secret with:
openssl rand -base64 32Export the variables for the Spring Boot process:
set -a
source .env
set +aStart PostgreSQL:
docker compose up -dStart the application:
./gradlew bootRunThe API will be available at:
http://localhost:8080/api
PostgreSQL applies POSTGRES_PASSWORD only when it initializes a new data directory. If the Docker volume already exists, DB_PASSWORD must match the password used when that volume was first created.
To remove the database and recreate it from scratch:
docker compose down -v
docker compose up -dThis command permanently deletes local database data.
IntelliJ does not automatically source .env. Add DB_PASSWORD and JWT_SECRET to the application Run Configuration before starting the service.
After starting the application:
- Swagger UI: http://localhost:8080/api/swagger-ui.html
- OpenAPI JSON: http://localhost:8080/api/v3/api-docs
The login endpoint returns a JWT valid for 24 hours. Protected requests must use the complete bearer authorization scheme:
Authorization: Bearer <token>The JWT subject is the user's email. Its roles claim contains Spring authorities such as ROLE_AUTHOR.
| Operation | Public | Reader | Author | Editor | Admin |
|---|---|---|---|---|---|
| Register and log in | Yes | Yes | Yes | Yes | Yes |
| List published articles | Yes | Yes | Yes | Yes | Yes |
| Read an article by ID | No | Yes | Yes | Yes | Yes |
| Create an article | No | No | Yes | No | Yes |
| Update an article | No | No | Own only | No | Any |
| Publish an article | No | No | No | Yes | Yes |
| Archive an article | No | No | Own only | Any | Any |
| Delete an article | No | No | Own only | No | Any |
New registrations receive the AUTHOR role. There is currently no public role-management endpoint.
All paths below are relative to /api.
| Method | Path | Authentication | Description |
|---|---|---|---|
POST |
/v1/users |
Public | Register a user |
GET |
/v1/users/{id} |
Required | Get a user |
POST |
/v1/auth/login |
Public | Log in and receive a JWT |
GET |
/v1/articles |
Public | List published articles |
GET |
/v1/articles/{id} |
Required | Get an article by ID |
POST |
/v1/articles |
Author or admin | Create a draft |
PUT |
/v1/articles/{id} |
Owner or admin | Update a draft |
POST |
/v1/articles/{id}/publish |
Editor or admin | Publish a draft |
POST |
/v1/articles/{id}/archive |
Owner, editor, or admin | Archive an article |
DELETE |
/v1/articles/{id} |
Owner or admin | Delete an article |
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"email": "author@example.com",
"password": "password123",
"displayName": "Example Author"
}'curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "author@example.com",
"password": "password123"
}'Example response:
{
"token": "eyJ...",
"tokenType": "Bearer",
"expiresIn": 86400
}If jq is installed, capture the token directly:
TOKEN=$(curl -sS -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "author@example.com",
"password": "password123"
}' | jq -r '.token')The author is derived from the authenticated JWT. Clients cannot choose an authorId.
curl -X POST http://localhost:8080/api/v1/articles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Clean Architecture with Spring Boot",
"summary": "A practical introduction to application boundaries.",
"content": "Article content goes here.",
"category": "Java"
}'curl -X PUT http://localhost:8080/api/v1/articles/1 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Updated title",
"summary": "Updated summary.",
"content": "Updated article content.",
"category": "Java"
}'Only draft articles can be updated.
curl "http://localhost:8080/api/v1/articles?page=0&size=20&category=Java"The maximum page size is 100. Results default to sorting by publishedAt in descending order.
DRAFT ──publish──> PUBLISHED
│ │
└────archive────────┴──> ARCHIVED
- New articles always start as
DRAFT. - Only drafts can be updated or published.
- An archived article cannot be archived again.
- Invalid state transitions return
409 Conflict.
Application errors use a consistent JSON structure:
{
"timestamp": "2026-07-13T12:00:00",
"status": 404,
"error": "Not Found",
"message": "Article not found: 99",
"path": "/api/v1/articles/99"
}Common status codes:
400 Bad Request— validation failed401 Unauthorized— missing or invalid authentication403 Forbidden— authenticated but not permitted404 Not Found— user or article does not exist409 Conflict— duplicate email or invalid article state
Flyway applies migrations from:
src/main/resources/db/migration
Hibernate uses ddl-auto=validate; it validates the entity mappings but does not create or modify tables. Add a new versioned Flyway migration whenever the database schema changes:
V2__add_article_slug.sql
V3__add_article_tags.sql
Do not modify a migration after it has been applied to a shared database.
The context test uses PostgreSQL, so start the Docker database before running the complete suite.
Run the complete suite:
./gradlew testRun a clean build:
./gradlew clean testThe test suite covers:
- Domain lifecycle behavior
- Application authorization and ownership rules
- User registration and email normalization
- Login orchestration
- Controller validation and authenticated identity mapping
- JWT role conversion
- Spring application context and persistence wiring
- Clean Architecture dependency rules
Test reports are generated under:
build/reports/tests/test/index.html
Every request logs its method, path, response status, authenticated user, and duration:
HTTP POST /api/v1/articles status=200 user=author@example.com durationMs=42
Authorization headers, JWT values, passwords, request bodies, and query strings are intentionally not logged.
| Property | Environment variable | Default |
|---|---|---|
| Database URL | — | jdbc:postgresql://localhost:5432/articlesdb |
| Database username | — | article_user |
| Database password | DB_PASSWORD |
Required |
| JWT secret | JWT_SECRET |
Required; minimum 32 characters |
| JWT expiration | — | 24h |
| Context path | — | /api |
| Maximum page size | — | 100 |
Production deployments should provide database credentials and JWT secrets through a secret manager or deployment environment rather than committed files.
# Start PostgreSQL
docker compose up -d
# View PostgreSQL logs
docker compose logs -f postgres
# Stop containers
docker compose down
# Run the application
./gradlew bootRun
# Run tests
./gradlew clean test