Complexity Is Never Free: Architecture Trade-offs from Building a SaaS Alone

Every box I add to the PigeonPod Cloud architecture diagram becomes another responsibility I need to carry: deployment, monitoring, upgrades, debugging, and every future change to that component. I build and operate the entire system alone, so none of those responsibilities will eventually move to another team.

This leads to the question behind most of my architecture decisions: Does the benefit of this complexity justify its full long-term cost?

The question sounds ordinary. The hard part is being precise about what counts as a cost and what counts as a benefit.

The workload behind PigeonPod Cloud

PigeonPod Cloud converts YouTube channels and playlists into private podcasts. After a user subscribes to a source, the system keeps it in sync, downloads new episodes according to the user’s settings, stores the media, and serves it through a private RSS feed to common podcast apps.

This flow involves external platforms, feed synchronization, media downloads, object storage, user access state, quotas, billing, and background execution. Those concrete problems determine what the system needs.

As of July 2026

  • PigeonPod Cloud has been live for about 120 days and has around 1,300 registered users.
  • The system has completed about 61,000 download tasks, averaging more than 500 per day.
  • It completes more than 16,000 successful feed synchronizations per day.
  • Over the past seven days, the average time from starting the first automatic episode download for a new subscription to completing that download was about 30 seconds.

These numbers set the practical boundary for the discussion. PigeonPod Cloud is still an early-stage product, but it already handles real users and a continuous production workload.

Complexity is never free

Architecture has both one-time and recurring costs.

A design takes time to implement. Understanding it consumes attention. Keeping it reliable requires infrastructure, monitoring, and operations. When something fails, I need to trace the user-visible symptom back to a specific state and a specific piece of code. When the product changes, I need to modify, migrate, or sometimes remove the design.

The benefit must also solve a specific problem. It might keep a user-facing service stable, isolate different failure and load profiles, provide capacity the system actually needs, or shorten the path from a problem report to its cause. “Cleaner architecture” is not enough on its own.

This is why I decide whether complexity stays by looking at its concrete return. Some complexity should be removed. Other complexity continuously prevents larger costs and is worth accepting from the beginning.

That cost-benefit judgment appears throughout the daily development of PigeonPod Cloud. The following examples show how it affects runtime boundaries, product scope, and infrastructure placement.

Architecture diagram

PigeonPod Cloud: Boundaries by Workload

The diagram includes only the user-facing path, the worker scaling unit, and the supporting systems in my mini homelab. It explains the trade-offs in this article rather than documenting the complete production topology.

Runtime boundaries should follow real workload differences

From the beginning, the PigeonPod Cloud backend has run the API and download workers as separate runtimes.

The API handles HTTP requests, authentication, orchestration for user actions, and background task submission. Workers handle feed synchronization, media downloads, object storage uploads, and other background work. They use separate Docker images and deployment stacks, while sharing the domain model, persistence code, MySQL, Redis, and object storage.

The first reason for this boundary is user-facing stability.

Download work consumes sustained network, CPU, memory, and local storage. It is also exposed to external-platform rate limits, changing content states, and failures in download tools. Its load is uneven, and its failure modes are more varied than those of a typical API request. Running it in the API process would allow background load to affect the service that responds directly to users.

Moving download execution out of the API changes that resource profile. Redis and a small set of in-process caches absorb high-value reads for sessions, RSS, media metadata, configuration, and selected external lookups. The API can then run with a relatively low and stable resource load. Both the API and workers matter, but the API has the higher stability priority because users depend on it for immediate responses.

The same boundary also makes a worker an independent scaling unit.

If download demand rises suddenly, or an existing node encounters an external-platform risk that cannot be resolved quickly, I only need to add worker capacity. A suitable cloud server, a homelab machine, or even an ordinary computer can become a download node after the required configuration and a single Docker image are in place. Once online, the node joins heartbeat and task routing and begins taking eligible download work.

This lets me increase download capacity while keeping API resources stable. A problem on one node also remains within background execution. Horizontal scaling and failure isolation are benefits that this workload genuinely needs.

Those benefits justify separate runtimes. They do not justify splitting the backend into microservices.

The api module depends directly on core. They share domain models, mappers, services, and a database. A request still uses in-process calls and the shared database, avoiding extra internal APIs and a general-purpose message broker. When I debug a problem, I can follow a relatively short path through the controller, service, mapper, and database query.

This design creates shared-database coupling, and core serves as both a shared module and a worker runtime. At the current stage, that is the boundary I want: separate the runtimes that need independent stability and horizontal capacity while keeping domain communication and debugging costs low.

Removing a finished product module

The original PigeonPod Cloud product design had two large areas: content conversion and content consumption.

Content conversion turns YouTube and other external content into private RSS feeds that work in podcast apps. Content consumption tried to let users discover, organize, and keep listening to content inside PigeonPod Cloud itself. Both areas reached production, and I spent substantial time completing the consumption experience.

It included browsing, recommendations and previews, a play queue, favorites, and cloud playback progress. Supporting those features added controllers, services, mappers, domain models, and database tables to the backend. It also added routes, pages, contexts, hooks, and separate desktop and mobile interactions to the frontend. It was a complete product surface.

After several months in production, instrumentation and usage data showed that very few people used it. Its direct infrastructure cost was close to zero, which made keeping it seem reasonable. It was already built, after all.

But code that remains part of a product participates in every future change. Whenever I changed the episode model, player behavior, user data, or frontend architecture, or investigated a related bug, I had to keep the consumption state in mind. The implementation, debugging, data maintenance, and migration surface kept growing.

The development time I had already spent was a sunk cost. Only the expected future benefit could justify the future maintenance cost. Before removing the features, I reviewed more detailed instrumentation and usage data and confirmed that the change would have little visible effect on almost every user. I then deleted the entire in-app consumption module.

This was much larger than hiding a few entry points. The cleanup changed 94 files, removed about 5,700 lines of code and related configuration, and dropped six database tables through a migration. The numbers describe the size of the change, but they also show how much maintenance surface a rarely used module had created.

PigeonPod Cloud still supports basic actions such as direct playback, downloading, copying links, and sharing. It no longer maintains the play queue, favorites, cloud playback progress, or the browsing system. I received no user complaints after the removal, which matched the earlier usage data. The product is now focused on content conversion, automatic updates, media delivery, and private RSS. Future changes no longer need to preserve an entire group of consumption states.

The lesson was direct: A feature keeps creating ownership cost for as long as it remains part of the product, even when its direct infrastructure cost is close to zero.

Treat the cloud bill as an early-stage architecture constraint

For a SaaS with limited early revenue and no outside funding, the fixed cloud bill is an architecture constraint from day one. It continuously reduces the time available for the product to validate its direction and become sustainable.

An early-stage product usually needs several rounds of validation, adjustment, and gradual growth. Higher fixed spending leaves less time to find stable users and revenue. I therefore account for cloud costs from the start. Depending on future growth to pay today’s bill shortens the period in which the product can keep learning.

Keeping the cost low requires selective use of self-hosted infrastructure. I place a workload by asking two questions: Is it in the synchronous user path, and what happens if it is temporarily unavailable?

The core services that respond directly to PigeonPod Cloud users run on cloud infrastructure. Loki, Grafana, Plausible, the JobRunr Dashboard, and a production mirror database for internal analysis run in my mini homelab. These systems sit outside the synchronous user path and support observability, analytics, and internal operations. If they are briefly unavailable, I lose some visibility and analysis capacity, but the core user flow continues.

This hybrid deployment strategy reserves cloud resources for the core user path and puts supporting systems on self-hosted infrastructure. Reducing the fixed cost gives the product more time to grow, validate its direction, and iterate.

Self-hosted infrastructure still needs maintenance, security, backups, and failure recovery. For workloads outside the core request path that can tolerate temporary degradation, however, I consider it a serious option for an early-stage SaaS. It preserves limited funds for the parts that genuinely need cloud reliability.

How PigeonPod Cloud keeps its fixed infrastructure cost below NZD 20 per month deserves a separate article because it involves service placement, cost definitions, and homelab design.

The point here is narrower: the cloud bill is itself an architecture constraint.

Every decision has an expiry date

The current architecture carries the real PigeonPod Cloud workload. Its useful life depends on how that workload and its constraints change.

Some parts were designed from the beginning; others evolved in response to production problems. The API-worker boundary belongs to the first group. Download tasks initially ran in JobRunr. I later moved them to db-scheduler after specific problems appeared in production, while other background jobs remained in JobRunr. The reasons, migration, and resulting boundary deserve their own article.

Both cases use the same decision process. Some constraints are clear enough at the start to design for them. Other problems only appear under a real workload. The first design should address known constraints. Later architecture decisions should recalculate costs and benefits as the evidence changes.

Independent team ownership, shared-database contention, or new reliability and scaling requirements could produce a different answer in the future. A boundary or abstraction that is too expensive today may become a sound investment then.

The reverse is also true. A component’s existence only proves that it made sense in the past. It must continue to earn its place.

Architecture maturity, as I understand it, is the ability to keep making that judgment: to know why I am paying a cost, what I receive in return, and when I should stop paying it.

In a production SaaS maintained by one person, every layer of complexity eventually returns to that person. Complexity worth owning solves a real problem. The rest borrows time from both the product and its developer.