Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .agents/rules/backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
paths:
- "backend/**"
---

# Backend rules

- Keep the layers: `controller` handles HTTP, `service` holds business logic,
`dao` is the only caller of a `repository` and turns entities into DTOs.
Entities never leave the `dao` layer.
- Code that runs around every request goes in `middleware/` as a `OncePerRequestFilter`
`@Component`, like `RequestLogger`. Log with Lombok's `@Slf4j`, never `System.out`.
- Inject dependencies as `private final` fields and put `@RequiredArgsConstructor` on
the class. Spring uses that constructor; no `@Autowired`, no hand-written constructor.
- Every controller lives under `/api/v1`. Give each method
`@Operation(operationId = "...")`, which becomes the function name in the frontend
client, and `@ApiResponse` with a description.
- Name DTO schemas after the resource with `@Schema(name = "Message")` and list
`requiredProperties`, so the client types match the other starters.
- Every repository query is `@Query(value = "...", nativeQuery = true)`. See
"Queries" in `AGENTS.md`.
- Change tables only through entities and `mise run db:dev:migrate:generate <name>`.
- After changing a controller or DTO, run `mise run contract` and commit
`backend/openapi.json`.
14 changes: 14 additions & 0 deletions .agents/rules/frontend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
paths:
- "frontend/**"
---

# Frontend

- Call the backend only through the generated SDK in `src/api/generated`. No raw
`fetch` to `/api`.
- If the SDK is missing a route or a field, change the backend and run
`mise run contract`. Do not hand-write types for API responses.
- Use relative `/api/...` URLs. Vite proxies them in dev and nginx proxies them when
deployed.
- Use arrow functions for components and handlers: `export const App = () => {}`.
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
**/node_modules
**/dist
**/.env
backend/.gradle
backend/build
frontend/src/api/generated
.git
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Docker Compose reads these and passes them into the containers.
SERVICE_USER_POSTGRES=app
SERVICE_PASSWORD_POSTGRES=app
POSTGRES_DB=app
POSTGRES_PORT=5432
# Used by commands that run on your machine, like db:dev:migrate:up. The backend
# container gets its own URL from compose.yaml.
DATABASE_URL=jdbc:postgresql://localhost:5432/app?user=app&password=app
LOG_LEVEL=info
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.bat text eol=crlf
*.jar binary
55 changes: 55 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# Every step runs the same mise task you run locally, so `mise run check`
# passing on your machine means these pass too.
jobs:
frontend_lint:
name: frontend / lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: jdx/mise-action@v5
- run: pnpm install --frozen-lockfile --dir frontend
- run: mise run lint:frontend

frontend_format:
name: frontend / format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: jdx/mise-action@v5
- run: pnpm install --frozen-lockfile --dir frontend
- run: mise run fmt:frontend

backend_lint:
name: backend / lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: jdx/mise-action@v5
- run: mise run lint:backend

backend_contract:
name: backend / contract
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: jdx/mise-action@v5
- run: mise run contract:check

backend_format:
name: backend / format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: jdx/mise-action@v5
- run: mise run fmt:backend
19 changes: 19 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.DS_Store
.idea/
.vscode/*
!.vscode/extensions.json

.env
.env.*
!.env.example

# backend
backend/.gradle/
backend/build/
backend/bin/

# frontend
node_modules/
frontend/dist/
frontend/src/api/generated/
*.tsbuildinfo
84 changes: 84 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Working in this repo

Rules for anyone changing code here, human or agent. Before editing `backend/` or
`frontend/`, also read the matching file in `.agents/rules/`.

## Running things

- The app always runs in Docker. `mise run dev:up` starts db, backend and web;
`mise run dev:down` stops them and keeps the data.
- mise loads `.env`, and Docker Compose passes it into the containers. Running
`./gradlew bootRun` or `pnpm dev` on your machine will fail on startup. That is
expected, not a bug to work around. Do not add default values to get past it.
- Lint, format and the OpenAPI export run on your machine through mise because they
never touch the database.
- `mise tasks` lists every task. Run `mise run check` before you finish. It is what
CI runs.

## Layout

```
browser -> web (vite / nginx) -> /api -> backend (Spring) -> db (Postgres)

backend/ controller -> service -> dao -> repository -> entity
middleware/ runs around every request (request logging)
frontend/ React app, API client generated from backend/openapi.json
```

## Writing code

- Read the code you are changing, and the code that calls it.
- Make the smallest change that solves the problem. No speculative abstractions,
config, or dependencies.
- Follow the patterns already in the repo before inventing new ones.
- Names carry the meaning. Comments explain why, not what.
- Prefer guard clauses and early returns over nested `if`s.
- Delete dead and commented-out code. Git has it.
- Do not swallow errors. Handle them or let them surface.
- In the frontend, write modern TypeScript: arrow functions, `type` instead of
`interface`, no `enum` or `namespace`.

## The database

The JPA entities in `backend/src/main/java/com/example/app/entity/` are the source of
truth for every table. Change the database only this way:

1. Edit or add an entity.
2. Run `mise run db:dev:migrate:generate <name>`. Hibernate compares the entities
with the database and writes the SQL to the next
`backend/src/main/resources/db/migration/V<N>__<name>.sql`.
3. Read the SQL. Hibernate never drops columns or tables, so add those statements
yourself. Then run `mise run db:dev:migrate:up`.
4. `mise run db:dev:migrate:status` shows whether the database is missing migrations.

In dev the backend never migrates on its own, so a pending migration only applies
when you run `db:dev:migrate:up`. The deployed backend applies pending migrations
every time it starts.

Migrations only go forward. Never edit a migration that is already on `main`. Add a
new one instead.

## Queries

Every query must read as SQL. Write it as a native query on a `JpaRepository`:

```java
@Query(value = "SELECT * FROM messages WHERE id = :id", nativeQuery = true)
Optional<Message> findByIdSQL(@Param("id") Integer id);

@Modifying
@Query(value = "INSERT INTO messages (text) VALUES (:text)", nativeQuery = true)
void insertMessage(@Param("text") String text);
```

- Never use derived query methods (`findByTextOrderByCreatedAtDesc`) or the built-in
`findAll`, `findById`, `save` and `delete*` methods.
- Never use JPQL, the Criteria API or string concatenation. Pass values as `:named`
parameters.

## The API contract

The controllers and DTOs are the source of truth for the API. `backend/openapi.json`
is generated from them and committed. `frontend/src/api/generated/` is generated from
that file and gitignored. Never edit either by hand. After changing a controller or
DTO, run `mise run contract` and commit `backend/openapi.json`.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Spring Boot React starter

React, Spring Boot and Postgres. `mise tasks` lists every task.

## Install mise

```sh
curl https://mise.run | sh
```

Then follow the printed step to activate it in your shell. See
<https://mise.jdx.dev/getting-started.html> for other installers. You also need
Docker.

## Set up

```sh
mise install
cp .env.example .env
mise run setup
```

## Run

Everything runs in Docker.

```sh
mise run dev:up # start db, backend and web
mise run dev:restart # after changing Java code
mise run dev:down # stop everything, data is kept
```

Open <http://localhost:5173>. The API is on <http://localhost:3000/api/v1>.

## Migrations

Tables are defined by the entities in `backend/src/main/java/com/example/app/entity/`. Change them there, then generate a migration.

```sh
mise run db:dev:migrate:generate add_something # write SQL from your model changes
mise run db:dev:migrate:up # apply pending migrations
mise run db:dev:migrate:status # is the database behind?
```

Migrations only apply when you run `db:dev:migrate:up`. Read the generated SQL before committing it. Hibernate never writes `DROP` statements, so add those yourself. There is no down migration: fix mistakes with a new one.

`mise run db:dev:studio` opens Prisma Studio on <http://localhost:5555>.
23 changes: 23 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM eclipse-temurin:25-jdk-alpine AS base
WORKDIR /app
COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle gradle
RUN ./gradlew dependencies --no-daemon -q > /dev/null

# Runs from source. compose.override.yaml bind-mounts src/ over /app/src, so
# `mise run dev:restart` picks up Java changes. Migration tasks run here too.
FROM base AS local
COPY src src
CMD ["./gradlew", "bootRun", "--no-daemon"]

FROM base AS build
COPY src src
RUN ./gradlew bootJar --no-daemon

FROM eclipse-temurin:25-jre-alpine AS deploy
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
USER app
COPY --from=build /app/build/libs/app-*.jar app.jar
EXPOSE 3000
ENTRYPOINT ["java", "-jar", "app.jar"]
68 changes: 68 additions & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import net.ltgt.gradle.errorprone.errorprone

plugins {
java
id("org.springframework.boot") version "4.1.1"
id("io.spring.dependency-management") version "1.1.7"
id("com.diffplug.spotless") version "8.10.4"
id("net.ltgt.errorprone") version "5.1.1"
id("org.springdoc.openapi-gradle-plugin") version "1.9.0"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}

configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}

repositories {
mavenCentral()
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.flywaydb:flyway-database-postgresql")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-api:3.1.1")
runtimeOnly("org.postgresql:postgresql")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
errorprone("com.google.errorprone:error_prone_core:2.50.0")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.withType<JavaCompile>().configureEach {
options.errorprone.disableWarningsInGeneratedCode = true
}

tasks.withType<Test> {
useJUnitPlatform()
}

spotless {
java {
googleJavaFormat()
}
}

// `mise run contract` boots the app with the openapi profile, which needs no
// database, and saves the spec to backend/openapi.json.
openApi {
apiDocsUrl = "http://127.0.0.1:3999/api/v1/openapi.json"
outputDir = layout.projectDirectory
outputFileName = "openapi.json"
customBootRun {
args = listOf("--spring.profiles.active=openapi")
}
}
Binary file added backend/gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
9 changes: 9 additions & 0 deletions backend/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading
Loading