Organizing source code in serverless projects

Symphonia
Mike Roberts
Apr 16, 2025

This is Cicada Note #6. For a background on Cicada and Cicada Notes, read Introducing Cicada.

A well-organized source code repository is a huge benefit to developers. A standard, sensible, structure makes it easier to develop and review new code, as well as bugfix, redesign, and repurpose existing code. This general idea holds for all software repositories, but what specific rules work well depends on the type and size of the project, as well as the languages used.

In an earlier blog entry I started describing how I organize a repository when working on serverless projects deployed as one application (not a monorepo), and where the language is JavaScript or TypeScript. That article described the files and directories I place in the project root.

One specific point I made in the earlier article was that “all application source - whether code or configuration - for runtime code, deployment scripting, and tools, lives somewhere under /src”. This article explains the “sensible defaults” for how I lay out the files underneath /src.

My guidelines mostly reflect how I’ve worked on projects for over two decades. One more modern concern, however, which throws some questions into the mix, is what to do about infrastructure-as-code (“IaC”) source code written in the same language as application source code. This is the case when using an IaC tool like AWS CDK or Pulumi.

The basic rule - organize by deployment target

To start, let’s look at Cicada’s /src directory. At the time of writing, there are five subdirectories:

  • /src/app/
  • /src/cdk/
  • /src/cloudfront/
  • /src/multipleContexts/
  • /src/web/

Most of the code in the tree is under three of these:

  • /src/app/ - Runtime application code
  • /src/cdk/ - Infrastructure-as-code deployment tool code (I use AWS CDK as my deployment tool)
  • /src/web/ - Static resources available via a web request

Code in each directory is executed in a different type of environment target:

  • Code in /src/app/ is bundled into an artifact and executed in a cloud-hosted runtime
  • Code in /src/cdk/ is executed from a local developer machine or within the deployment pipeline
  • Code in /src/web/ is downloaded to a user’s device and run in their browser

Typically I also store statically-defined environment configuration under /src/cdk/ - things like log levels and DNS names. Cicada partly does that (see here), but it’s something of a special case since it’s an open source application, and so I can’t capture every possible environment configuration in source control. In a private project I’d put a lot more concrete configuration into source files within /src/cdk/, or equivalent.

The main reason I like organizing code in this way is that I approach interactive development and testing quite differently, according to each target:

  • I focus on testing code in /src/app/ using local tests, however I also use cloud-deployed code for remote tests and when I’m experimenting
  • I usually only execute code in /src/cdk/ when running a deployment process
  • Most of the time I’ll only try to work in /src/web/ using a local copy of the code unless I’m dealing with a deployment concern

As I mentioned in the previous article, all tests, and code used only by tests, lives somewhere under /test/, and not under /src/.

Breaking the rule for special cases

Of course not all code fits neatly into this small rule. Cicada has two other directories apart from the main three.

Firstly, there’s /src/cloudfront/ - This is source code for a CloudFront Function. CloudFront functions are different to regular application source code:

  • They aren’t compiled / bundled
  • They use a specific JavaScript runtime with a limited standard library
  • They run inside CloudFront’s “Edge” locations, which have some particular environment considerations

Because my CloudFront function has such a different lifecycle to my other application source code, and has such different runtime constraints, I like to keep it separate from the main application source code.

As a further example: if I’m working on a project that uses a relational database I often keep my SQL files in a separate location under /src/. Depending on how extensively SQL is being used I may even separate this further to migration SQL code that is run during deployment vs. application SQL code that is run within the application code at runtime.

In Cicada there’s also that /src/multipleContexts/ directory. This needs a bigger discussion…

Application and infrastructure code in the same language

Before about 6 years ago it was pretty simple to decide how to separate application code from its infrastructure code (the code used during the deployment of the application). Mostly this was because the two types of code were written in different languages, and often different types of language. They might even have been written by different teams. Because of this it made sense to store them in different parts of a repository, if they were even stored in the same repository at all.

However, it’s now possible that your application code and infrastructure code will be written in the same language. This is often the case when using CDK from AWS, or Pulumi. It’s now quite common when using these tools to colocate the two types of code in the same repository since often the same team will be developing with both. So where should each type of code live in the source tree?

When I first saw these tools, people often demonstrated them by having both the infrastructure code and application code in the same directory, and sometimes even the same file. I found this deeply troubling! After all, this code has drastically different lifecycle and environmental contexts - surely (I thought) they should be completely separated with different processes and policies?

Over time, however, I’ve come across a number of places where I found myself duplicating code between infrastructure and application contexts. These usually fall into one of two groups:

  • Small utility functions, equivalent to something like missing standard library behavior
  • Environment definitions, e.g. environment variable names and database table names

This second group becomes even more common in a serverless environment, where we “offload” a lot more responsibility to the underlying platform. An example is forking threads of execution to secondary Lambda Functions via messaging queues or topics. In such a case both the application code and infrastructure code will need to know the concrete names of the queues or topics.

Developers’ brains are wired to reduce duplication, and so I’ve softened my stance and now think that in specific cases it can make sense to have shared source code between application and infrastructure runtimes. However, I still think that such code is best treated as an exception and managed separately from the bulk of code in a source tree.

This is what the /src/multipleContexts/ directory is in Cicada - a location where I keep code used both at runtime and at deployment time.

An example in this directory of a utility function is errors.ts, where I keep a small function I use often in TypeScript code.

Most of the code in this directory, however, relates to environmental issues like configuration, database tables, and messaging. For example, in Cicada I keep some amount of application configuration in SSM Parameters. The names of these parameters are used in both CDK deployment and Lambda application code. Because I don’t want to duplicate these names (which could lead to errors), I keep them in a shared file, which partly looks like this:

export const SSM_PARAM_NAMES = {  
  GITHUB_APP_ID: 'github/app-id',  
  GITHUB_CLIENT_ID: 'github/client-id',  
  GITHUB_PRIVATE_KEY: 'github/private-key',  
  // ... etc.
} as const

What about Lambda?

I’ve titled this article “Organizing source code in a serverless project”, but so far I’ve only mentioned serverless in passing. That’s by design, and in fact I lay out source code for a container-based application very similarly to what I’ve described so far.

A great benefit to this is that if we want to switch between Lambda and Container-based runtimes then there’s no big source tree organization required. In fact we can easily deploy both Lambda and Container-based workloads from the same tree, and even in the same deployment process (yes - serverless and serverful workloads can live happily together!)

There has to be some Lambda-specific code though - at the very least for the “handler” entry points. I use a standard pattern for this: in Cicada, each top-level Lambda handler is in a file named:

  • /src/app/lambdaFunctions/LAMBDA_FUNCTION_NAME/lambda.ts

For example, the Lambda Function that sends Web Push events to user devices is /src/app/lambdaFunctions/webPushPublisher/lambda.ts .

I use this pattern both to simplify some deployment tooling configuration and also to help developers know when they are working in Lambda-specific code vs. runtime-generic code.

I try to keep the amount of Lambda-specific code small so that (a) as much code as possible can be tested locally and in-process with tests, and (b) so that switching between Lambda and Container hosts requires less work.

Summary

Here’s how I organize code in a serverless project:

  • Put all application code AND deployment / infrastructure code under /src/
  • In most cases, subdirectories under /src/ are for different runtime contexts
  • When using the same language for your application and infrastructure code keep them mostly separate, with a different directory under /src/
  • … However it can be useful to share a small amount of code between application and infrastructure code to avoid duplication, but keep such code in its own subdirectory.
  • I try to keep my source tree structure as similar as possible for serverless vs. container-based runtime targets
  • Use a standard for locating each Lambda handler entry point

Feedback

I enjoy hearing what people think about how I’ve built Cicada, and also about my writing in Cicada Notes. If you have feedback either email me at mike@symphonia.io, or contact me at @mikebroberts@hachyderm.io on Mastodon, or at @mikebroberts.com on BlueSky.