Bitcoin price alert project

Purpose of the project

The idea behind Bitcoin price alerts is simple: subscribe to a given price to receive an email alert.

The really interesting part is how to build a stateless architecture that could scale to millions of users using Go and AWS / Terraform. To be clear, the design is what scales, the demo configuration deliberately doesn’t (more on cost later). I didn’t want to over-engineer the project from the beginning. Over-engineering is a defect in itself, so the idea was to keep the base simple and leave the door open for future upgrades if needed.

You can follow along the code here:

https://github.com/nixmaldonado/btc-alerts

Or go straight to a demo here:

https://btc-alerts.nixmaldonado.com/

One heads up about the demo: SES runs in sandbox mode, so emails only get delivered to addresses I verified beforehand. If you create an alert and no email lands, that’s the free tier doing its thing, not a bug. More on that in the cost section.

How I built this (in the age of AI)

AI is here to stay, and at this point it’s just another tool in my belt. It helps me deliver functional services faster. But there is a caveat of course, since all coins have two sides. We should never delegate everything to thinking machines. The Dune saga already explained how that ends, and I’d rather not be the guy who triggers the Butlerian Jihad over a Bitcoin price alert. So I start by sketching the general architecture myself, using Excalidraw.

After I have a clear picture of what I would like to build, I poke around the idea and iterate over the draft on my own. Once I believe the design is good enough, I ask my trusty clanker what it thinks about the tradeoffs I decided upon and whether it can see any gap I missed. It’s the same reason I like throwing ideas around with colleagues that are savvy in different topics. AI lets me do that without stealing time from another human that is probably busy, and the insights are usually very good. Once I’m satisfied with the results, I arrive at the final form:

BTC Alerts data flow: an EventBridge-scheduled evaluator Lambda fetches BTC prices from CoinGecko and writes fired alerts to DynamoDB; a DynamoDB Streams trigger drives a Notifier Lambda that sends email via SES; users configure alerts through API Gateway.
The stateless, event-driven architecture behind BTC Alerts.

Architecture decisions

One of the core decisions was using one lambda to evaluate the price and another to send the notifications. This way notifications only fire for committed writes, so an alert can’t be lost. Firing itself is exactly once at the data layer: the evaluator transitions the alert from ARMED to FIRED with a conditional update, so a retry can’t double-fire.

// FireAlert conditionally transitions an alert ARMED→FIRED. The status=ARMED condition
// makes firing idempotent (a retry can't double-fire), and REMOVE on gsi_pk/gsi_sk drops
// the item from the sparse index.
ConditionExpression: aws.String("#status = :armed"),
UpdateExpression:    aws.String("SET #status = :fired, #firedAt = :now REMOVE #gpk, #gsk"),

Pick up reading from FireAlert in store.go.

The notifier adds a second guard: it only sends when a stream record represents a transition into FIRED, so re-writes of an already fired item never re-send (that check lives in shouldNotify in notifier.go). The one thing I don’t fight is the delivery contract of DynamoDB Streams plus Lambda, which is at least once. In a rare retry scenario the same email could go out twice, and for a price alert that’s a tradeoff I can live with. Batches that keep failing report partial failures and route to an SQS dead letter queue instead of blocking the stream.

Also I discarded SNS in favor of the DynamoDB stream because it fit our use case better: one alert maps directly to one recipient, and that’s the wrong shape for SNS pub/sub architecture.

The original idea was to use a “price fetcher” and an SQS queue with the prices in there, but after staring at that design drawing for a while I noticed we could simplify it by just using a price evaluator lambda triggered by EventBridge every minute and drop the SQS queue entirely. The fewer moving parts the better.

I think this example illustrates (pun intended) that drawings are a powerful tool to aid human thinking, not AI (they get really confused by drawings in my experience). Improvements become obvious when you see the system end to end, and a big part of that is always asking what would happen if some component were removed. We should always strive for the simplest system that supports the functionality we need. The fewer components we have, the easier it is to maintain and reason about the system.

Another key decision was the design of the DynamoDB table. Originally I thought of using the email of the user as the key of the alerts, but this would mean that anyone with a valid API key could potentially spoof their way into reading another email’s alerts. While if we use the API key itself as the primary key, then authentication is baked into the request itself and is what really proves ownership of an alert. And as a secondary consequence of this, the email lives in a single PROFILE item per owner, and the notifier resolves the recipient from it at fire time. Updating where notifications land is one write that applies to every alert the owner has, including ones already created.

Also I decided to prepend the “OWNER#” prefix to the primary key for a couple of reasons:

  1. Avoid collisions with future top-level entities (since we are using a single table).
  2. It’s used as a filter for the DynamoDB stream.
  3. It helps in legibility / observability in logs and debugging.

That second point pays for itself in Terraform: the notifier lambda never even wakes up for records that aren’t alerts, the filtering happens at the event source mapping. The SK prefix matters too, since the table also holds a per owner PROFILE item and a STATE#PRICE singleton the evaluator rewrites every minute. Without the filter, that once a minute price churn would invoke the notifier for nothing.

# Only alert items (PK begins with OWNER#, SK begins with ALERT#) wake the Notifier.
filter_criteria {
  filter {
    pattern = jsonencode({
      dynamodb = {
        Keys = {
          PK = {
            S = [{ prefix = "OWNER#" }]
          }
          SK = {
            S = [{ prefix = "ALERT#" }]
          }
        }
      }
    })
  }
}

Pick up reading from the event source mapping in streams.tf, which also wires the partial batch failures and the dead letter queue mentioned above.

For the alerts I decided to use a sparse GSI in the same table with this shape:

Primary key:

ARMED#ABOVE
ARMED#BELOW

Secondary key: the target price, zero padded to a fixed width so that string order matches numeric order. That way the evaluator can query a price range directly.

The armed below or above is fixed at creation time. The index is sparse because only armed alerts carry the GSI attributes. Firing removes them, so fired alerts drop out of the index on their own and the evaluator never scans dead entries.

This design of course would prove to be a hot partition for the alerts given the low cardinality of the primary key (only 2 possible values).

A more robust approach would use a series of price buckets in the primary key, ARMED#<dir>#<priceBucket>, so the cardinality would grow with the data. This is not perfect of course, since alerts would probably tend to cluster on psychologically significant price levels, but it’s an improvement over the demo format.

In any case I kept the naive version to make the project easier to follow. It’s a good base to build a more complex system upon.

Considerations about cost

Since this is a demo project I wanted to be as cost effective as possible. Some tradeoffs were made that otherwise would not make sense in a real production environment.

One of those points was DynamoDB capacity. The original plan was provisioned mode below the free 25 read/write units, since on-demand has no free request tier. In practice provisioned billed me anyway: the unit hour clock runs 24/7 and every terraform apply and recreate cycle rounds partial hours up, even while nominally sitting at the free ceiling. So I switched to on-demand, where this app’s near zero traffic rounds to about $0, and set hard caps of 100 read/write request units on the table. Above the caps, requests throttle instead of billing, which restores the cost ceiling. Denial of wallet is also bounded at the edge: every endpoint sits behind an API key with a usage plan of 1000 requests per day and 5 rps.

SES is used in sandbox mode, because getting into production mode needs a dedicated AWS ticket and could potentially be used to send a lot of emails that go beyond the free tier. The tradeoff is that we can only send 200 emails per day, to accounts that were previously verified by the user.

Also the lambdas are kept outside a VPC because they would need a paid NAT Gateway for outbound traffic. Outside the VPC they can call CoinGecko directly and the whole project stays comfortably inside the free tier.

Wrapping up

This project is small on purpose. The alerts are the excuse. The real exercise was designing a stateless, event-driven system with the fewest moving parts possible, and being honest about the tradeoffs when cost is a constraint.

If you want to poke around, the code and the demo are linked at the top. And if you would have made a different call on any of these decisions, I’d like to hear about it.