3 important decisions I made when setting up the tech stack for my startup

Starting a new project can be equal parts fun and terrifying. There are so many decisions to be made that will impact how its development is done for years to come and it's impossible to know how that all plays out. This is especially true at an early-stage startup where speed is crucial and your product will evolve exponentially as you look for market fit. Your stack needs to adapt efficiently and not get in the way of rapid iteration cycles while maintaining its structural integrity.

Over the last year, I've been building Wellen and I wanted to talk about some of these key decisions that I've made with our tech stack and how they've played out so far.

Monorepo vs. Polyrepo

Go with a Monorepo (and enforce these 3 important rules)

Drawing on some frustrations at a previous job, I decided to go with a mono repo very early on. This setup empowered me to easily:

  • Make changes that impact the entire stack in one pull request.
  • Quickly add and integrate more apps without much bootstrapping.
  • Set up a development environment that can run everything with one command.

While these points are not unique to a mono repo, the barrier of entry to getting this setup was incredibly low. I had the shell of my stack setup in hours and the structure hasn't changed much as we have scaled.

There are a lot of counter-points to a mono repo, particularly at scale (dependency management, coupling, performance, complexity, etc) but I think you can address a lot of those arguments with these enforceable rules:

  • Rule #1 -- Each app/service must be independently deployable. yarn deploy web-app , yarn deploy api
  • Rule #2 -- Each app/service must use shared libraries via a package manager (npm, gems, etc). yarn add @internal/packageRule
  • Rule #3 -- Each app/service must have the same interface for building, testing, and linting. e.g yarn build , yarn test , yarn lint

The end result is a stack that lets me add apps and packages very quickly, with the ability to make sweeping product changes in a single change request. I also have the comfort in knowing that because of these rules, if the decision is made to split the mono repo up later, it will not be as painful as it could be.

One downside of a mono repo is that you do need to invest more time and energy into scripts and tooling to manage your dev and build pipelines. For example, it took me some time to figure out how I could set up a GitHub action that only ran when changes were detected in a specific sub-folder.

Fortunately, you are able to easily trigger builds when changes are detected in a particular directory:

name: app-1-build
on:
  push:
    branches: [main]
    paths:
      - "app-1/**/*.tsx"
      - "app-1/package.json"
      - "app-1/yarn.lock"
  pull_request:
    branches: [main]
    paths:
      - "app-1/**/*.tsx"
      - "app-1/package.json"
      - "app-1/yarn.lock"

Another issue I ran into (which I plan on talking about a lot more, later) is the developer experience. Initially, you had to cd into each app/service to run commands like yarn test or yarn serve.

This context swap is minor, but annoying, as you have to do a lot of folder navigation in the terminal.

My current solution is to just add yarn commands at the project root, which do all that for you:

yarn web-app test or yarn api test

This definitely works, but there's a lot of tooling out there specifically to manage mono repos, a lot of which is documented here:

Monorepo Explained

Setup and enforce linting immediately

No-brainer.

I never start a new project without immediately setting up a linter and autoformatting. Inconsistent code style or a lack of basic static analysis is a huge source of stupid bugs and developer frustration.

One might argue that the upfront cost of investing in linting is too much when you're trying to rapidly iterate but I completely disagree for these reasons:

  • CI/CD pipelines are cheap and easy. eg Github actions, CircleCI, etc.
  • Linters are common, well-supported, and popular in all languages.
  • Most ship with recommended suggestions that require zero or minimal configuration.

A lot of times you'll see teams neglect this early on, leading to multiple style choices across multiple services. When you jump into an environment like this, you end up losing cycles on just parsing the format vs. the syntax or intent of the code itself. Setting up strict linter rules (and failing builds accordingly) is crucial so that your code style does not drift over time as you add more people to your project.

Some engineers argue that code style limits their productivity or freedom to be creative, but that's a pretty thin argument. Adhering to the needs of a single engineer's personal preference compromises the productivity of everyone else.

On top of that, code style is just one part of linting:

  • You can run a linter for security or performance checks (such as rubocop-performance for ruby)
  • You can run a linter for accessibility compliance (eslint-plugin-jsx-a11y)

In my case, I had this running from day 1. A lot of my stack is React, Typescript, and TailwindCSS, all three of which I have a ton of linting being done with eslint. When I switch between my services, the code style can always be expected to be consistent. When I add new developers to the project, they are unable to create style drift by adopting their own standards.

Here are some rules/plugins that I have found to be super beneficial:

  • eslint-plugin-simple-import-sort - This ensures that my import statements are always ordered exactly the same at the top of my files. e.g React first, third party next, then my own components after.
  • eslint-plugin-tailwindcss - This ensures my tailwind classes are always sorted consistently and do not have duplicates or conflicting uses.
  • eslint-plugin-jsx-a11y - Baseline accessibility compliance for aria tags is pretty nice to have at this stage.

Rebuild your stack constantly

The new developer experience is crucial.

Typically when you're just starting off you'll have a rotating cast of developers coming through your project. In-house, freelancer, in-shore, off-shore, etc. With that sort of cast, it's important that the time it takes for them to get onboarded, set up, and start coding is highly optimized.

This is something I struggled with early on until I took the time to do a few things right.

Use Docker

While often unpopular with developers because of its overhead, I think it's at the point where the performance concerns on Mac/Windows have been mostly invalidated by faster machines and the new virtualization frameworks that have been shipping with Docker desktops.

In my case, I decided to go with a single root-level docker-compose.yml setup that lets me run my entire stack with one command:

version: "3.9"
services:
  # ------------------------------------------
  # web-app (React/Typescript)
  #
  # running on port 3000
  # ------------------------------------------
  web-app:
    depends_on:
      - rails-api
      - strapi-cms
    build:
      context: web-app/
      dockerfile: Dockerfile.dev
    restart: unless-stopped
    command: yarn start
    volumes:
      - ./web-app:/app:cached
      - web_app_node_modules:/app/node_modules:cached
    ports:
      - 3000:3000
  # ------------------------------------------
  # design-system (React/Storybook)
  #
  # running on port 3005
  # ------------------------------------------
  design-system:
    build:
      context: valesco/
      dockerfile: Dockerfile.dev
    restart: unless-stopped
    command: yarn storybook
    volumes:
      - ./valesco:/app:cached
      - valesco_node_modules:/app/node_modules:cached
    ports:
      - 3005:6006
    environment:
      - CHOKIDAR_USEPOLLING=true
  # ------------------------------------------
  # rails-api (Rails)
  #
  # running on port 3008
  # ------------------------------------------
  rails-api:
    depends_on:
      - rails-db
      - rails-redis
      - strapi-cms
    tty: true
    stdin_open: true
    build:
      context: rails-api/
      dockerfile: Dockerfile.dev
    restart: unless-stopped
    command: [ "rails", "server", "-b", "0.0.0.0" ]
    volumes:
      - ./rails-api:/app:cached
      - bundle:/usr/local/bundle:cached
      - cache:/app/tmp/cache:cached
    ports:
      - "3008:3000"
    environment:
      PIDFILE: /tmp/pids/server.pid
    env_file: rails-api/.env
    tmpfs:
      - /tmp/pids/
  # ------------------------------------------
  # rails-db (Postgres)
  #
  # database for rails-api running on port 5432
  # ------------------------------------------
  rails-db:
    image: postgres:14-alpine
    volumes:
      - ./rails-api/bin/pg_hba.conf:/var/lib/pg_hba.conf:cached
      - rails_api_db:/var/lib/postgresql/data:delegated
    environment:
      POSTGRES_PASSWORD: ...
    ports:
      - "5432:5432"
    command: [ "postgres", "-c", "hba_file=/var/lib/pg_hba.conf" ]
    # ------------------------------------------
    # rails-redis (Redis)
    #
    # redis cache for rails-api
    # ------------------------------------------
  rails-redis:
    image: redis:6-alpine
    command: redis-server
    volumes:
      - rails_api_redis:/data:delegated
    ports:
      - "6379:6379"

  # ------------------------------------------
  # rails-sidekiq (Rails/Sidekiq)
  #
  # sidekiq workers for rails-api
  # ------------------------------------------
  rails-sidekiq:
    depends_on:
      - rails-db
      - rails-redis
    build:
      context: rails-api/
      dockerfile: Dockerfile.dev
    restart: unless-stopped
    command: bundle exec sidekiq
    env_file: rails-api/.env
    volumes:
      - ./rails-api:/app:cached
      - bundle:/usr/local/bundle:cached
      - cache:/app/tmp/cache:cached
  # ------------------------------------------
  # strapi-cms (Rails)
  #
  # running on port 3009
  # ------------------------------------------
  strapi-cms:
    build:
      context: strapi-cms/
      dockerfile: Dockerfile.dev
    restart: unless-stopped
    env_file: ./strapi-cms/.env
    command: [ "yarn", "develop" ]
    environment:
      - DATABASE_CLIENT=postgres
      - DATABASE_HOST=...
      - DATABASE_PORT=...
      - DATABASE_NAME=...
      - DATABASE_USERNAME=...
      - DATABASE_PASSWORD=...
    ports:
      - 1337:1337
    volumes:
      - ./strapi-cms/.:/app:cached
      - strapi_cms_node_modules:/app/node_modules:cached
    depends_on:
      - strapi-db
  # ------------------------------------------
  # rails-db (Postgres)
  #
  # database for rails-api running on port 5433
  # ------------------------------------------
  strapi-db:
    image: postgres:14-alpine
    restart: always
    volumes:
      - strapi_cms_db:/var/lib/postgresql/data:delegated
      - ./strapi-cms/bin:/opt/bin:cached
    env_file: ./strapi-cms/.env
    environment:
      POSTGRES_USER: ...
      POSTGRES_PASSWORD: ...
      POSTGRES_DB: ...
    ports:
      - 5433:5432

volumes:
  web_app_node_modules: null
  strapi_cms_node_modules: null
  bundle: null
  cache: null
  rails_api_db: null
  rails_api_redis: null
  strapi_cms_db: null

Write a provisioning script that does everything

This can be difficult when your project pulls in many dependencies but having a single entry point to rebuild the entire stack on a whim empowers you to run it often.

For example, here is my provisioning setup:

// package.json
{
    "provision": "yarn provision:envs && yarn provision:docker && yarn provision:db",
    "provision:envs": "cp api/.env.example api/.env && cp cms/.env.example cms/.env",
    "provision:docker": "docker-compose -p wellen down --volumes && docker-compose -p wellen up --build -d",
    "provision:db": "yarn provision:db:cms && yarn provision:db:api",
    "provision:db:api": "docker-compose -p wellen run api-db sh -c \"/opt/bin/setup-db.sh\"",
    "provision:db:cms": "docker-compose -p wellen run strapi-db sh -c \"/opt/bin/setup-db.sh\""
}

As a new developer, you can run yarn provision which will:

  • Delete all volumes, start from a clean state.
  • Rebuild all Docker containers, so dependencies all up to date.
  • Setup new databases
  • Seed databases with development data

This runs in only a few minutes and results in an easy way to start the entire stack from scratch. Because this is so easy, developers can do it a lot which in turn makes sure that the new developer experience is smooth. With this setup, I rebuild my entire stack weekly which lets me catch potential onboarding issues early and often.

More to come!

This is a topic I could write about for a long time, so we'll leave it at this for now.