Pawloop
An app that brings together dog-walk tracking, a map of pet-friendly places, shared family care and schedules, a neighborhood community with crews, and chat. Planning, the Flutter app, the Spring Boot server and the AWS infrastructure were done solo; it was released on ONE store in September 2026 and is in operation. It was written with an AI coding agent, and about 81% of commits carry a co-author trailer.
Role
Solo — planning, Flutter app, Spring Boot server, AWS infrastructure, ONE store release, operation
Period
2026.06 – present · released on ONE store 2026.09
2.0.0
Released on ONE store
binary 2026-09-18 · update 09-21
23
Decision records (ADRs)
planning, legal, design, app, server; 18 compare alternatives
1,994 · 0 failed
Full backend test run
GitHub main on 2026-09-30, about 18 min
Screens

Walk

Family care

Community

Map

Home
From planning to operation
| Stage | When | What | In the repository |
|---|---|---|---|
| Planning | pivot 07-01, docs added to the repo 09-01 | Changed direction from an emotion-based travel app to a pet app; defined the family-care problem (double feeding, missed walks); reviewed the Location Information Act | docs/00-product (concept, personas, feature-family-care, legal-lbs-compliance), ADR-0001 |
| Design | added to the repo 08-29 | Design system, information architecture, screen specs. The API contract is the source of truth, checked against code and tests (88 paths, 122 operations). 23 decision records | docs/01-design, docs/, docs/ |
| App | 06-29 – | 35 Flutter screens, a draft queue that resends walks that failed to save, one location-upload policy, adaptive chat polling, social sign-in deep links | app/lib/v2 |
| Server | 08-30 – 09-10 domain modules | Modular monolith (22 top-level modules), 38 Flyway tables, 5 after-commit events, early-morning cleanup jobs, server-side calls to external APIs | backend/ |
| Infrastructure | 09-16 – 09-17 | Terraform production environment (one EC2 instance, Elastic IP, Route 53, separate EBS, SES), production config in SSM, Caddy as the single entry point | backend/ |
| Release | 09-17 – 09-21 | Changed the Android applicationId, published the terms and privacy policy site, ONE store 2.0.0 binary (09-18) and update (09-21) | store/ (store graphic sources), Caddy static site |
| Operation | 09-16 – | Manual deploy procedure, one path for changing production config (a script), CI (tests, analyze, doc drift), fixing problems on the first production boot | envs/, backend/, . |
Starting point: the repository began with the code of the June 2026 HeatTrip app redesign (private; sign-in, media upload and so on) and turned into a pet app on 2026-07-01. Of today’s code, about 5% of server lines and 3% of app lines still come from that starting code (git blame at commit b0f89f6).
The planning and design documents live in the same repository as the code; many came in through AI co-authored commits.
System architecture
- The only door open to the whole internet is Caddy (443/80). SSH (22) is open only to an allowed IP range. The app container is published only on the host’s 127.0.0.1:8080, and the DB is not exposed.
- The keys for the public-data APIs (Korea Tourism Organization, Durunubi, business licensing, KMA weather, AirKorea) and the AI API stay on the server. The app gets that data through the server.
- Coordinates can leave the device in one place only:
LocationUploadPolicy. Whether they are sent is set by a compile-time constant, which is off by default.
Server and data
- It is one Spring Boot app, and its 22 top-level packages are Spring Modulith modules. The groups are walks (dog, walk, walksafety), family and schedules (group, care, schedule), messaging (chat, chatweb, crew, community, moderation, moderationweb, push), places (place), accounts (user, OAuth, consent) and common (S3, security, config, error, observability).
- The module-boundary check is a ratchet added later; the violation baseline went from 620 at first to 107 today (zero cycles between modules).
- 21 FKs cross module boundaries, most of them pointing to users and dog. A test pins that list as a baseline. The chat module has no outside dependencies (
allowedDependencies = {}), and no FK crosses its boundary. Rooms point to families and crews throughorigin_kind/origin_ref, and schedules (owner_kind/owner_ref) and media (ref_type/ref_id) also use reference pairs without FKs. Media that were uploaded but never attached to anything for a day are removed by a cleanup job at 04:10 every day. - Idempotency for mobile resends is enforced by DB constraints:
UNIQUE(dog_id, client_walk_id)for walks andUNIQUE(room_id, sender_user_id, client_msg_id)for chat.
After-commit events (in-memory, publisher → subscriber)
ChatMessagePosted: chat → push (sends FCM)ScheduleAssigned: schedule → push (notifies the assignee)UserSessionsRevoked: user → push (cleans up device tokens)FamilyGroupCreated: group → care (creates default care items)ReportFiled: moderation → observability (Slack alert; production config unconfirmed)
The Modulith event registry is not used (ADR-0012).
Design decisions
These are four decisions that span planning, app, server and infrastructure. The first three are in the decision records (ADRs); the reasons for the front door are in the message of the commit that set it up.
Location: one place where coordinates can leave the device
The location-based service business filing has not been made, so the nine places where coordinates could leave the device were gathered into one class, LocationUploadPolicy. Whether coordinates are sent is set by the compile-time constant UPLOAD_LOCATION (default false). The server’s route storage was not removed.
Nearby places are computed on the device from a downloaded nationwide catalog. On the server, walk.path (LINESTRING, SRID 4326) has no SPATIAL INDEX, because no query looks up walks by location.
The cost: with the constant off, the screens for saving a course and browsing neighbors’ courses are hidden.
Chat: generic rooms, HTTP send with polling, per-room sequence
Rooms are not tied to a family; they attach through origin_kind/origin_ref, so families and crews share them. Sending is an HTTP POST and receiving is polling. The app sends GET …/messages?afterSeq= with If-None-Match and gets 304 when there is nothing new. The polling interval starts at 3 seconds, doubles on every empty response and caps at 30 seconds.
WebSocket and Redis were deferred on the grounds of a single instance and the size of rooms. The criteria for revisiting that (empty-poll ratio, p95, connection wait) are written in the ADR.
The cursor is the per-room room_seq, not id. The number is taken in one step with the room row’s lock and a session value.
UPDATE chat_room SET last_seq = LAST_INSERT_ID(last_seq + 1) WHERE id = ?1
-- in the same transaction, read the number with LAST_INSERT_ID(), then INSERT chat_message
A test on real MySQL (Testcontainers) has 20 threads send to the same room at once and pins that sequence numbers 1–20 each come out once. The test uses the default connection pool of 10, so at most 10 transactions actually run at once.
Deleted messages and reactions do not arrive in the room_seq delta. So a separate per-room change_seq is issued. Re-issuing room_seq was rejected because old messages would drop to the bottom and unread counts would go up.

When another member deletes a message, the next poll turns it into a tombstone in the same place.
The cost is receive latency (an interval of up to 30 seconds). The criteria for revisiting cannot be measured in production yet (limitation 3).
Public-data quota: a grid cache and a daily budget
The Korea Tourism Organization API allows 1,000 calls a day. Within that, there is a snapshot cache per 0.02° grid cell (place_cell), and a daily call budget (kto_call_budget) counted with a conditional UPDATE on a single DB row.
The app holds no public-data keys. The server calls the Korea Tourism Organization, Durunubi, business licensing, KMA weather and AirKorea APIs on its behalf. The walk-safety index takes only a grid number and passes the result through without storing it.
The catalog is warmed at 04:40 in the morning, and the licensing and Durunubi data are refreshed at 05:20 on the first of each month. The cost is that the server carries external API outages and call costs.
The front door and a single server
Production is Docker Compose on one EC2 instance built with Terraform. The front door is Caddy 2.10 instead of nginx. Caddy handles automatic certificate issuance and renewal, X-Real-IP injection and the static terms site, and lets only health through under /actuator/*.
The DB is not RDS; it is a MySQL 8.0 container on the same EC2 instance. Only the data directory sits on a separate EBS volume (prevent_destroy). Production config (.env, deploy key) lives in SSM Parameter Store and is fetched at boot. There is one way to change it: a script.
There is one RDB, and Redis was deferred. The four things that would have to move once there are two instances (the one-time authorization code store, rate-limit counters and others) are listed in the ADR. The cost is a single point of failure, a short outage during deploys and no backup procedure; these are in the limitations table below.
Before release and on the first production boot
| Date | What happened | What changed |
|---|---|---|
| 09-16 | On staging, after terraform apply, five problems left the EC2 instance unable to do anything at boot | Fixed the boot script |
| 09-17 | The first boot failed on a missing or outdated buildx and on assets missing from the image. Local CI reported “all passed” from cached results (0 tests actually ran) | Check the buildx version, not just whether it exists. Run tests fresh every time with cleanTest --no-build-cache and count the result XML files |
| 09-17 | Caddy did not start. On the first production boot, a Caddyfile syntax error (two path matchers on one handle) made it exit right away, so 80/443 did not respond, while the test that checked strings in the Caddyfile was passing. The mount point for nested bind mounts was also missing | Replaced that test with one that actually runs caddy validate in the same Caddy image as production; created the mount point and added a static check of the compose files |
The ONE store 2.0.0 binary went up on 09-18 and an update on 09-21.
Limitations and next steps
| Limitation | Impact | Next step |
|---|---|---|
| One server, no load test | An instance failure means an outage, and the throughput limit is unknown. The push send queue is in memory (2–4 threads, queue of 1,000, drops when full) | Before adding instances, move the four items listed in the ADR |
Manual deploys (SSH → git pull → docker compose up -d --build) | There is a short break while containers are recreated. The entry point is sh -c, so SIGTERM may not reach the JVM | Automate image builds and deploys; exec-form entry point |
| Production observability is actuator health only | Custom metrics (chat.seq.assign and others) are recorded but there is no collector, so the chat-polling revisit criteria (empty-poll ratio, p95) cannot be measured in production | Add a metrics collector |
| Chat read-marking locks in the opposite order to sending | When the two overlap, a deadlock reproduces (currently a 500 response) | Unify the lock order |
| No DB backup procedure | MySQL is a container on the same EC2 instance, with only the data volume split onto EBS (prevent_destroy). The production Terraform and production README have no backup or restore procedure | EBS snapshot schedule and a restore rehearsal |
The source is private; a demo or code walkthrough is available on request. Basis: GitHub main b0f89f6 (2026-09-26).