DEV Community

Cover image for From Custom Resources to Constructs: Designing a CDK Library for Serverless UDP
Lee Harding
Lee Harding

Posted on AI-assisted

From Custom Resources to Constructs: Designing a CDK Library for Serverless UDP

We recently shipped @proxylity/cdk-udp-gateway, an AWS CDK construct library for Proxylity UDP Gateway. This article presents how it was designed and built — what the library does, and the method we used to get it right: the layering strategy, the two design problems that shaped the API, the verification discipline that caught issues before they shipped, and the unglamorous engineering (toolchain, publishing, CI) that supports these open-source libraries. If you build infrastructure as code, or you're thinking about wrapping your own service in CDK, follow along.

A quick word on CDK

The AWS Cloud Development Kit lets developers define cloud infrastructure in a traditional programming language — TypeScript, Python, Java, C#, Go — instead of declaritive CloudFormation JSON or YAML. You compose constructs (reusable, typed building blocks) into stacks, and CDK synthesizes the CloudFormation. The benefit of moving from configuration to code is: types, autocompletion, loops and conditionals, unit tests, and the ability to package and share higher-level abstractions.

CDK constructs are conventionally described in three levels, and that's the vocabulary used for the rest of this article:

  • L1 — a 1:1, mechanical mirror of the underlying CloudFormation resource. No opinions, no defaults beyond what the resource itself applies. The faithful escape hatch.
  • L2 — an ergonomic, opinionated wrapper: sensible defaults, typed references to other resources, enums instead of magic strings, and automatically-derived IAM.
  • L3 — full solution patterns that wire several resources together for a complete use case.

Most of the developer-experience value lives at L2. But you don't get a trustworthy L2 by skipping L1 — and that belief shaped our approach.

The starting point: a service, and some boilerplate

Proxylity UDP Gateway brings UDP into the serverless world. You get a managed listener endpoint — UDP, WireGuard, or DTLS — and inbound datagrams are delivered to AWS destinations (Lambda, SQS, Kinesis, Firehose, S3, Step Functions, and more) as batched events. You have no servers to run, patch, or scale.

Up until this release, developers provisioned a listener through CloudFormation custom resources — Custom::ProxylityUdpGatewayListener and its companions. They work well and they are the authoritative contract for the service. But authoring them by hand involves careful adherence to syntax and boilerplate:

  • You resolve an account/region configuration mapping and wire a service token and API key into every resource.
  • For every destination, you hand-build an IAM role with the correct trust relationship and a least-privilege policy — and "least privilege" means knowing exactly which API actions that particular delivery performs.
  • You get a wall of precisely-named properties right, by hand, in JSON.

Every destination is a small research project in "which IAM actions does this actually need, and scoped to what resource?" Get it subtly wrong and the stack deploys cleanly, then fails at runtime.

Over the past two years the service has matured and stabilized. A stable contract is the precondition for building a good abstraction on top of it — there's no point wrapping a moving target. That stability was the signal to invest in developer experience: to let people express a UDP service in a few lines of typed code instead of a hundred lines of JSON, in the languages they already use.

Principle 1: build L1 first, and keep it

It was tempting to jump straight to the convenient API, but we deliberately didn't. We built a faithful L1 layer first and kept it as a distinct, permanent layer underneath L2.

The reason is deverloper control and trust. L1 is an un-opinionated mirror of the custom resources: if the resource accepts a property, L1 exposes it; if the resource lacks a property, L1 does not invent it. That makes L1 simultaneously:

  1. The foundation L2 is composed from. Everything L2 does, it does by assembling L1 constructs. L2 never emits raw CloudFormation itself and never bypasses the L1 mapping. There is exactly one place in the whole library where the opinionated layer meets the wire format, and it's L1.
  2. A permanent escape hatch. When L2's opinions don't fit your case — some advanced option L2 didn't surface, some combination it didn't anticipate — you drop to L1 without leaving the library. You're never stranded between "the easy API that doesn't do what I need" and "hand-writing CloudFormation again."

Keeping the layers separate costs a little duplication. It buys you a library that is both easy and transparent.

Principle 2: verify against reality, and write down every surprise

This is the discipline I'd most want another team to steal.

An opinionated layer is a set of claims: this default is sensible, these IAM actions are what the delivery needs, this property maps to that wire field. Every one of those claims can be wrong. So we treated the deployed service's actual behavior as the single source of authority — not documentation, not example templates, not our own mental model — and we verified each claim against it.

Where our assumptions, the docs, and the real behavior disagreed — and they did — we didn't quietly pick one. We kept a running log of every discrepancy, each with the observed truth and the decision we made about it. Some discrepancies were ours to fix in the library. Some were things to work around and flag. The point is that none of them got lost or papered over: a surprise you don't record is a bug to rediscover later.

Two practices made this concrete:

  • A golden synth-diff. The service ships known-good example templates. For each, we author the equivalent stack with our constructs, synthesize it, and assert the resulting custom-resource output matches the proven template property-for-property. If our abstraction ever drifts from the real contract, a test goes red. This single technique is the backbone of the whole library's correctness.
  • Review that blocks on unverified claims. Every design and implementation pass was reviewed with one non-negotiable rule: an IAM grant or a behavioral claim that isn't grounded in verified behavior is a defect, not a detail. More than once, that rule caught a plausible-looking permission that would have been accepted at deploy time and then failed silently at runtime — caught at design time, before a line of code existed. Getting per-service, least-privilege IAM exactly right (the correct actions, scoped to the correct resource, correct across AWS partitions like GovCloud and China) is genuinely subtle, and the only defense is rigor.

If there's one transferable lesson from the project, it's this: the quality came from the verification, not from cleverness. The clever parts were small. The disciplined parts were everywhere.

Designing L2: two problems that shaped the API

With L1 solid and a verification harness in place, L2 is where the developer-experience investment lives. Two design questions dominated.

Four protocols, one resource

A listener speaks exactly one protocol, and each protocol has its own relevant settings — WireGuard has peers, DTLS has pre-shared keys and early-data windows, WebRTC has a signaling mode. The naive design is four separate listener classes. But a listener is fundamentally one resource: one allocated endpoint, one shared set of destinations. Protocol is a configuration dimension, not a different kind of thing. Four classes would either duplicate the shared surface four times or force an inheritance hierarchy.

Our answer is a single Listener construct with a sealed protocol factory:

new Listener(this, 'Service', {
  protocol: ListenerProtocol.wireGuard({ peers: [/* ... */] }),
  destinations: [/* ... */],
});
Enter fullscreen mode Exit fullscreen mode

ListenerProtocol.udp(), .wireGuard({...}), .dtls({...}), and .webRtc({...}) are static factory methods, each exposing only the properties relevant to that protocol. You cannot set a WireGuard peer on a UDP listener — the type system makes it unreachable. And because the only way to get a protocol is through one of these factories, the "exactly one protocol" rule is guaranteed structurally; there's no way to express an invalid combination.

This pattern is also a jsii necessity, not just an aesthetic choice (more on jsii below): the library compiles to five languages, and not all of them have the discriminated-union types you'd reach for in TypeScript. A sealed type produced by factory methods is the portable way to say "one of these shapes."

Destinations that derive their own IAM

The single biggest pain in the raw custom resource is the per-destination IAM. So L2 provides a family of per-service destination constructs, each taking a typed reference to the target resource and deriving its own least-privilege role:

new LambdaDestination({ fn: myFunction })
Enter fullscreen mode Exit fullscreen mode

That one line creates the IAM role with the correct trust relationship, grants exactly the actions that destination's delivery performs — scoped to the target resource and its versions — applies a sensible default payload formatter, and wires the resource dependency so the listener never activates before its destination's policy exists. There is a specialized destination for each supported service — Lambda, SQS (including FIFO handling), Kinesis, Firehose, S3, SNS, Step Functions, DynamoDB, API Gateway, CloudWatch Logs, EventBridge, IoT, and Location — plus a generic ArnDestination escape hatch for anything not yet specialized.

The resulting value is that the correctness of the IAM is now the library's responsibility, verified once, instead of every developer's research project rediscovered every time.

The unglamorous engineering

A construct library is more than its constructs. A surprising amount of the work was in the surrounding machinery — and most of it carried lessons worth sharing.

One codebase, three languages (and two more to come). The library is authored in TypeScript and compiled with jsii to npm, PyPI, and NuGet (with Java and Go reachable from the same build). jsii enforces a cross-language-safe public API: no union types, structs follow naming conventions, and every exposed type must itself be jsii-compatible. This is liberating and constraining at once — we write once and ship everywhere, but our API design is now also a portability problem. The protocol factory above is partly a response to that constraint.

Peer dependencies, zero shipped dependencies. The library references CDK types so you can pass a real lambda.Function or sqs.Queue, but aws-cdk-lib stays a peer dependency — the consumer brings their own CDK version, and the published package ships no runtime dependencies of its own. This keeps the dependency surface tiny and avoids version conflicts in consumer projects.

Dependency hygiene is a feature, not a chore. Partway through, a routine check surfaced a stale, vulnerable development toolchain. The trap here is declaring victory on a shrinking number — "we cut the advisory count by a third" is not the same as "the dependency tree is clean." We held ourselves to a stricter bar: every dependency we control is current and supported, and every remaining advisory is either demonstrably not shipped to consumers or explicitly documented with the reason it can't be resolved yet. That meant chasing transitive dependencies to their root, choosing between a safe current release and a disruptive major version deliberately (and sometimes learning a non-obvious fact — like a compiler's versioning scheme — that changed the decision), and documenting the residue rather than hiding it behind a smaller count.

Keyless publishing. CI/CD runs on GitHub Actions with OIDC trusted publishing for all three registries — no long-lived registry tokens stored anywhere, with build-provenance attestations on the npm package. This is the current best practice for supply-chain hygiene, and it's worth the setup: there is no secret to leak because there is no secret. It is not, however, free of sharp edges — trusted publishing has real prerequisites (public source repositories for provenance, exact publisher-name matching, environment-scoped approvals) and the error messages when you get them wrong are not always illuminating. Budget time to get the first release green.

Tests that would have passed locally failed in CI. One of our verification tests read a sibling file that happened to exist in the local checkout but not in the CI environment. It passed every time locally and failed the moment it ran in the pipeline. The fix — vendor the fixture into the repo so the test is self-contained — is obvious in hindsight; the lesson is that "self-contained" is a property you have to design for, not assume.

The result: raw CloudFormation vs. L2

Let's see the payoff, with real code. Below is the "packet counter" scenario — a UDP listener that delivers to a Lambda function with metrics enabled — expressed in raw CloudFormation and in the new CDK constructs.

Raw CloudFormation custom resource. Notice that the hand-written IAM role — its trust relationship and its grant — is most of the template, and all of it is knowledge the author has to carry in their head:

{
  "Resources": {
    "DestinationRole": {
      "Type": "AWS::IAM::Role",
      "Properties": {
        "AssumeRolePolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [{
            "Effect": "Allow",
            "Principal": { "AWS": { "Fn::FindInMap": ["ProxylityConfig", { "Ref": "AWS::Region" }, "ServiceRole"] } },
            "Action": "sts:AssumeRole"
          }]
        },
        "Policies": [{
          "PolicyName": "AllowLambdaInvoke",
          "PolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
              { "Effect": "Allow", "Action": ["lambda:InvokeFunction"], "Resource": [{ "Fn::GetAtt": ["PacketCounterLambda", "Arn"] }] },
              { "Effect": "Allow", "Action": ["cloudwatch:PutMetricData"], "Resource": "*" }
            ]
          }
        }]
      }
    },
    "UdpListener": {
      "Type": "Custom::ProxylityUdpGatewayListener",
      "Properties": {
        "ServiceToken": { "Fn::FindInMap": ["ProxylityConfig", { "Ref": "AWS::Region" }, "ServiceToken"] },
        "ApiKey": { "Fn::FindInMap": ["ProxylityConfig", "Account", "ApiKey"] },
        "Protocols": ["udp"],
        "ClientRestrictions": { "Networks": [{ "Ref": "ClientCidrToAllow" }] },
        "Destinations": [{
          "Description": "Delivers to the packet counter lambda.",
          "Role": { "Arn": { "Fn::GetAtt": ["DestinationRole", "Arn"] } },
          "Batching": { "Count": 1000, "TimeoutInSeconds": 1 },
          "MetricsEnabled": true,
          "DestinationArn": { "Fn::GetAtt": ["PacketCounterLambda", "Arn"] }
        }]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That's also missing the configuration mapping block you have to remember to add — and it only works if you already know the trust principal, the exact invoke action, and the metrics permission.

The same thing in L2 CDK:

const config = ProxylityConfig.forStack(this);

const listener = new Listener(this, 'UdpListener', {
  protocol: ListenerProtocol.udp(),
  clientRestrictions: { networks: [clientCidr.valueAsString] },
  destinations: [
    new LambdaDestination({
      fn: packetCounter,
      description: 'Delivers to the packet counter lambda.',
      batching: { count: 1000, timeoutInSeconds: 1 },
      metricsEnabled: true,
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

The configuration mapping, the token and key wiring, the IAM role and its trust relationship, the invoke grant, the metrics permission, and the dependency ordering are all derived. The synthesized CloudFormation is equivalent — the test suite asserts exactly that — but the authoring experience is a handful of typed, reviewable lines where the compiler catches your mistakes. And because it's jsii, the identical API is available in Python, C#, and soon Java and Go.

Conciseness is the obvious win. The quieter, bigger win is that the correctness knowledge — which permissions, scoped how — moved out of every developer's head and into a verified library.

Try it, and build something

The library is open source: github.com/proxylity/cdk-udp-gateway, available on npm (@proxylity/cdk-udp-gateway), PyPI (proxylity-cdk-udp-gateway), and NuGet (Proxylity.CDK.UdpGateway).

UDP wasn't a part of the serverless story until UDP Gateway made it so. Game servers, telemetry, IoT, VoIP signaling, syslog, DNS, RADIUS, and encrypted tunnels all speak it — and none of them fit neatly into the request/response, HTTP-shaped world most managed services assume. Proxylity UDP Gateway is uniquely positioned to make UDP a first-class, event-driven citizen of your AWS architecture, which is exactly what hybrid and edge-connected applications need. With a proper CDK library on top, standing up a serverless UDP service is now a few typed lines in the language you already use.

If you run anything that speaks UDP — or you've been avoiding UDP because the serverless tooling wasn't there — give it a try, and tell us what you build.


Proxylity and UDP Gateway are trademarks of Proxylity LLC. AWS, CloudFormation, Lambda, and the AWS CDK are trademarks of Amazon.com, Inc. WireGuard is a trademark of Jason A. Donenfeld.

Top comments (0)