Catchlist Architecture Review

Project Overview

Catchlist is primarily an effort to build a small application with the infrastructure and practices I'd expect to encounter in a commercial application: a frontend/backend split, dev/staging/production environments, CI/CD, automated testing, and an architecture where responsibilities have clear boundaries.

Catchlist is secondarily a productivity application; it intends to provide API and Webapp interfaces through which the user can manage "todo" tasks, projects, scheduled routines, and commitments, with reporting across daily/weekly/monthly/seasonal/annual timeframes.

Tech Stack

Catchlist uses

  • Python/Flask : webapp/api services
  • SQLAlchemy : database
  • Git and Jenkins : version control and deployment
  • Selenium/Pytest/Allure : test automation

System Structure

The guiding principle is that all behavior should have an obvious home. All entities follow a consistent pattern:

Entity (model)
  ↓
Repository (BaseRepository or UserOwnedRepository)
  ↓
Service (business logic, validation)
  ↓
Presenter (data transformation for API/webapp)
  ↓
API Routes (HTTP handlers)
  ↓
Webapp Routes

Model/Repository/Service Layers

Models define the shape of the application's data and its relationships in/to the database. Keeping models lightweight reduces coupling between persistence and application behavior, making them easier to understand, test and extend if necessary.

Repositories handle persistence and ownership checks. BaseRepository provides generic CRUD, while UserOwnedRepository adds user-scoped access; entity-specific repositories extend these when they need specialized queries. Business workflows remain in services, allowing them to coordinate multiple repositories without embedding database logic.

Services contain business rules and workflows, coordinating repositories for operations such as cascading calendar changes or completing project subtasks. Routes therefore remain thin and persistence concerns stay out of business logic.

API Route Layers

Route Handler functions verify JWT tokens, ensure valid request shape, call service layer functions, and format the result into an http response.

Deployment Strategy

Development uses local orchestration scripts; staging/production use systemd/Gunicorn with Jenkins handling deployment and test execution.

Tradeoffs

API / Webapp split

Drawback
Building two services instead of one adds complexity to the deployment system and how data is accessed. Two launch scripts, two systemd service files, separate test frameworks, etc.

Benefit
The split enforces a separation between presentation code and backend logic, which makes testing, extension, and maintenance simpler. Additionally, the split enables a variety of ways to use the application. The webapp automatically supports both desktop and mobile usage, but an AI agent could use a read-only access token and the API documentation to perform bespoke reporting tasks.

Reasoning
Creating both an API and webapp is a pretty direct manifestation of "all behavior should have an obvious home". Small files with clear purposes are easy to read, write, understand, and transpose to similar use cases; a bit of complexity is a fair trade.

Timezone Strategy

The typical advice for handling timestamps and timezones is "store everything in UTC, convert as far out towards the user as possible (ie the webapp or api routes). This is fine for instances, but recurring events can run into edge case issues with DST changes, so for recurring events (but not materialized instances of a recurring event), it's necessary to store the IANA timezone string, ical RRULE, and local time representation, which preserves the intent (eg every Tuesday at 9pm)

Drawback
More complex and non-uniform timezone handling. Some entities are just stored in UTC and the only conversion we need to do occurs when we convert user-provided times from the user's timezone to UTC; some entities store IANA timezone strings and require conversion elsewhere (eg creating instances of a recurring event)

Benefit
In addition to recurring routines now invisibly handling DST changes, we can allow a timezone parameter when creating sessions from routines, enabling a user to keep their routines going when visiting a different timezone without affecting previous sessions. It's generally a more flexible system which preserves intent.

Reasoning
Honestly, the application only has one user and supporting visits to different timezones is something we can get away with not doing, so it would have been possible and much simpler to just hardcode America/Chicago as the application's timezone or just ignore timezones completely. However, if this application gradually accumulates a lot of user data and then one day changing the timezone is needed, implementing handling for them becomes a much more perilous task. Far better to support this out of the box instead of inviting a huge data migration down the road and establishing a pattern of shrugging off functionality because there's only one user.

Explicitness over Minimalism

This manifests in a few places but it's a design principle, so it should be represented throughout the application.

Most entities have an EntityRepository that's a pretty thin wrapper around the base repository. It would certainly be possible to refactor that out and fetch database entities directly from the service layer, but then there's no longer an obvious answer to where authentication checks should happen, and they'd probably get embedded into the service layer's business logic.

With an explicit repository layer, an operation such as changing a calendar's color can cascade the change down to all routines and sessions it owns by importing the associated repositories and using the update function without putting persistence logic for multiple entities in the service layer.

The existence of explicit Timeframe and Commitment entities are another example. They could be removed, replaced with lists of timestamps or start/end tuples on each schedulable entity. That would reduce the number of model/repo/service files, but reporting would immediately become more cumbersome because the application would need to reconstruct a list of commitments by examining each schedulable entity instead of resolving one timeframe and getting the connected list of commitments.

Simple is better than complex, but complex is better than complicated.

Lessons Learned

Modeling Recurring Events

When dealing with timestamps/timezones, the common advice appears to be 'store everything in UTC and convert at the edge/frontend'. This works well for individual events. Recurring events complicate this: "every Tuesday at 10pm America/Chicago" describes a local time, not a fixed instant. Its UTC representation can change across DST transitions.

Catchlist's strategy for representing the schedules of recurring events evolved from the "convert at the edge, store in UTC", to a model that preserves the schedule's intent:

  • Store the user's IANA timezone string (e.g. America/Chicago)
  • Assume user-provided timestamps are in the user's local time unless an offset or timezone is provided
  • For routine templates, store local time-of-day, timezone, and iCalendar recurrence rules to preserve the intended schedule
  • For created instances (sessions) and any other non-recurring entities, we can use the simpler method of storing UTC datetimes

This preserves the semantic meaning of recurring events while keeping other entities simpler to query. The tradeoff is more complex and non-uniform timezone handling, and implementation is still stabilizing from the change, but this method more accurately models recurring events at the cost of that complexity.

Testing

Coming from a QA background where selenium was my first taste of test automation, I naturally gravitated towards end-to-end testing. In retrospect, that produced an inverted testing pyramid.

The application has a glut of end-to-end tests which are fairly brittle; I've had to change or disable some tests in order to pass builds as pieces of the frontend or API was refactored because of this inverted testing pyramid.

Additionally, the UserOwnedRepository is intended to enforce ownership of user data by overwriting the base repository's CRUD methods and requiring the authenticated user's ID and limiting queries to entities owned by the same user. However, I discovered during this architectural review that UserOwnedRepository only overwrites the getter functions, leaving update and delete exposed. The issue went unnoticed because route handlers perform authorization by calling get() before update() or delete(), causing all end-to-end security tests to pass. While the application's behavior is currently correct, the authorization guarantee exists only as a convention in the route layer rather than as a property of the repository abstraction. Unit tests in the repo layer would have immediately found this issue.

If I rebuilt the project today, I would push the majority of testing into unit tests for the repo and service layers, reserve integration testing for cross-entity behaviors based on how frequently I expected them to occur for the user or change in the app, and build a select few end-to-end tests to validate the most ironed-out, frequent, critical, and unchanging application behaviors.

The Selenium framework, custom API client, Jenkins integration, and Allure reporting still remain useful foundations. Future work will focus on shifting the bulk of testing toward unit and integration tests while preserving a smaller set of end-to-end tests for critical user workflows.

Repository Drift

Some repositories include functions that have drifted away from simple getters and convenience methods and actually contain business logic.

Even though it's a solo project, a lightweight review checklist for PRs could prevent this problem by inviting investigation into whether any additions to a given module match its purpose (eg if we're modifying a repository, is any added code strictly related to persistence logic?).

Security

The API is the application's trust boundary.

All requests accessing or modifying user data require a valid JWT access token; the webapp acts as an ordinary API client and does not bypass API authentication or authorization.

User-scoped repositories provide the authorization boundary for user-owned data. UserOwnedRepository accepts the authenticated user's ID and restricts queries to records owned by that user, keeping ownership checks out of individual routes and service functions.