Skip to content

Development

Engineering approach

When I work on a project, small or large, this is the approach I like to take to designing and building a solution. This is written from the perspective of a new solution but could be adapted for existing solutions too.

High Level Design

The first place I start is a high level design. I don’t go into great detail or try to design a final system – I simply include the basic components I’ll need to get to something deliverable. This usually includes software components such as APIs (e.g. customers, orders, stock, etc…) and infrastructure (e.g. Kubernetes cluster, identity provider, secret storage, etc…). Sometimes specific technologies will be listed if these are known (e.g. if it’s an Azure house, then Azure Kubernetes Service (AKS), Entra ID and Azure Key Vault). This may be done as one diagram or two.

Breaking Up Work

I then take this high level design and think about the features and stories it will produce. Again, at this stage, nothing too detailed but thinking more about delivery of “something” even if it’s of no use.

As an example, let’s say we know we will be using AKS to host our application and have decided we will do this setup rather than look at container apps as a first stage. We’ve also decided we’ll be using a GitOps workflow (e.g. Flux) for deployments and, to start with, we want a simple API which has some kind of API key based access management. The initial requirements to get this API hosted and accessible might be:

  1. Set up repos for infra and API
  2. Create infrastructure*
  3. Create simple API
  4. Configure Flux
  5. Configure pipelines

* This first iteration may not be as secure or hardened as would be preferred but it’s nothing more than a first step with no data at risk.

Set up repos for infra and API

If Infrastructure-as-Code (IaC) is being used to manage your repos, simple add the two new repo names and you’re done. If still doing manually, add the appropriate repos after agreeing a naming convention. This is what I class as a sprint 0 story as it needs doing to unblock work on one or more other stories without creating (too many…) dependencies in a sprint. If running with a Kanban approach, this would be the first story to tackle.

Create infrastructure

Again, if an existing IaC setup is being used, this may be a very quick process. If not, infrastructure could be created manually or using something like Terraform. If time and skills permit, I’d opt for Terraform to ensure long-term consistency and it also makes it easier to iterate through changes as work progresses.

Remember that in this initial phase, only a single, small environment is required.

Create simple API

This is referring to nothing more than an API with a suitable set of stub endpoints. Nothing needs to be real in terms of real CRUD activities, etc… If helper libraries exist, it’s recommended these be used to simplify setup and ensure consistency. For example, this helper library could set up things like logging, basic health checks or config file loading.

As a strong advocate of Test Driven Development (TDD), I recommend starting with a TDD approach from the beginning, even for this “hello world” API.

Services should be build independently as possible and as loosely coupled as possible so it’s recommended any config, even that which will come from things like Azure Key Vault, reference local files and let Kubernetes manage this dependency. For databases such as SQL Server or Azure Storage, local Docker versions can be used, not that any of these should be needed for this first cut of the app.

Configure Flux

Flux is a GitOps tool and whilst isn’t strictly necessary for the first cut, without resorting to more complex pipelines and manual deployments, is a reasonable first step.

If you’re using a common and shared Helm chart for your services, that should significantly speed up the release of services as it will handle a lot of the boiler plate configuration and ensure consistency.

This initial Flux setup may want to include nothing but the ability to deploy an application using a Helm chart or could include broader requirements such as ingress. As things such as port forwarding can be used at these early stages, this may be required.

Pipelines

Hopefully a set of pipelines already exist in the form of templates that can be called with minimal parameters but, if not, this first pipeline should only do the basics of building and pushing an image to a container registry. If using Azure, Azure Container Registry (ACR) would be recommended for easier authentication.

It’s also highly recommended that a pull request pipeline be created at this time. Even when using trunk-based development, the process of going through a PR pipeline can ensure all tests are passing and security scans are successfully completed.

Potential Stories

This requirements above should lead to specific work items. These should be as small as possible (i.e. completable in no more than one day) whilst still delivering “something of value”. For example, say setting up the Terraform for new infrastructure will take 2-3 days based on previous experience, a single story for all this work would be undesirable. A better breakdown would be:

  1. Provision global resource group with ACR instance – this could include setting up the repo and storage account to hold the state
  2. Provision suitable Entra groups and grant appropriate access to ACR
  3. Provision AKS cluster and needed VNET
  4. Provision a service principal for use with pipelines
  5. Create pull request pipeline which runs a terraform plan and is called from the infrastructure repo
  6. Create a deploy pipeline which runs a terraform apply and is called on any merging to main
  7. Setup a repo for the Flux configuration and bootstrap the cluster using Terraform

Notice that this list encompasses parts of several of the initial high level tasks identified. At this very early stage, some dependencies on previous stories is to be expected.

It’s likely that existing resources (e.g. pipelines, code templates or libraries, etc…) exist which will make some of these tasks quite small.

Methodology

Before you start “doing stuff”, the work management approach needs to be considered. An agile approach would be an obvious in today’s world with Kanban and Scrum being the two more common approaches taken.

Scrum usually consists of work blocks (known as sprints) of 1 to 4 weeks with 2 weeks being the most common. Each sprint has a goal and collection of work to achieve that goal with some that “must” be completed, some that “should” be completed, some that “could” be completed if all goes to plan and some that “won’t” be completed unless work is completed much quicker than expected. This is called MoSCoW prioritising and is a typical method used. You’ll often see 1 to 4 used instead of MoSCoW but they often translate to the same basic understandings.

Kanban is more like a continuous list of work with the queue regularly reviewed, resulting in work being added, reprioritised and potentially removed.

Either approach supports trunk-based development with regular releases i.e. there’s no need to wait until the end of a sprint to do a release.

Story Writing

Once the approach has been agreed, the team (engineers, product owner, etc…) should agree on what stories are needed, write them, refine them and agree on a priority. Once enough work has been prepared for the first sprint or first week or two for Kanban, work can commence.

As time goes on, it’s good to have one or two sprints ahead (1-2 months of work) planned out. Things can always be reprioritised but this helps begin to see a bigger picture and set time expectations.

The Components

Once the first batch of stories have been written, work can commence. For the various areas of engineering, I endeavour to adhere to the following:

Infrastructure-as-Code (IaC)

All infrastructure should be done as IaC to ensure consistency and repeatability. My tool of choice is Terraform and whilst this tool isn’t perfect, it’s the most popular tool available and, as such, has a large amount of support available and works with many providers including Azure, AWS, GitHub, SonarCloud, etc…

If something is to be tested by manual changes, it is recommended that these changes are made in a separate area (e.g. subscription) to the infrastructure managed by IaC.

Code

All code should be written using a Test Driven Development (TDD) approach with tests testing requirements (i.e. inputs and outputs) rather than the inner workings of a method. Ultimately a series of unit, integration and end-to-end tests should be developed and executed as part of release pipelines.

Any code committed to main must be releasable to production. This doesn’t necessarily mean it can be used on production – a feature gate could be keeping it hidden – but the code should be safe to release to production.

The use of tools for static code analysis and Snyk can help scan code and containers for code or security issues ahead of any code being released to a server.

Logging, observability and monitoring are essential to keeping a system healthy and diagnosing problems when they arise. Suitable tooling should be in place such as Prometheus and Grafana or Datadog should be in place as early as possible in the development stage. The use of OpenTelemetry is strongly encouraged to enable easier migration to different tooling.

The observance of things like SOLID, DRY, KISS, etc… are always encouraged.

APIs and Microservices

When building microservices and APIs, I align to the following rules:

  1. Communication through interfaces – any communication, whether REST, gRPC or class, will communicate through interfaces and, for message-based/event driven applications, . agreed schema
  2. Any service must only communicate to another service or the data belonging to another service through the above interfaces – no service A looking at service B’s data . directly
  3. Any service must be independently deployable
  4. Any service should gracefully handle any unavailability of an external service
  5. Any API must be built to be externalisable (i.e. can be exposed to the public internet)
  6. Within a major version of an API, any changes should be non-breaking – when breaking changes are needed, a new major version should be created and the previous version(s) maintained

Separation of Concerns

It is strongly recommended that, where possible, things such as authentication, configuration, credentials, etc… are managed independently of the code. In other words, an application running locally should use local configuration files to store non-sensitive configuration (e.g. a connection to a local SQL Server instance is OK but nothing that is hosted). If sensitive values must be stored, use things like User Secrets as these aren’t part of the repo and therefore not stored in source control.

For loading sensitive values, where possible use managed identities (or equivalents) and, where these aren’t possible, try to use (service) accounts that are machine created (likely with IaC) and their credentials stored in things like Key Vault and never actually accessed/known by humans. These credentials along with other configuration values can then be made available to applications (i.e. pods) in Kubernetes using tools like External Secrets Operator and Azure App Configuration Provider and mounting them as files into the pod. Equally, storage can be mounted in a similar manner.

Putting It All Together

Work should be selected from the top of the queue with a goal to complete active stories (or bugs once they come in) before picking up new ones. In other words, pull requests should be given priority and then, before picking up a new story, see if you can potentially assist on an already active story.

When is it done?

It’s important to have a clear definition of done so that all parties agree what this means. As a minimum, it should mean a story has successfully completed a pull request to main but arguably it should mean, as a minimum, QA (either automated or manual) has been completed. I would suggest something is only “done” once it has been successfully deployed to production and confirmed to be working.

The NuGet Packages I Use

This article is simply a list of the common NuGet packages I use. This list should be viewed as a “this is what I do” list rather than a recommendation. This isn’t an exhaustive list and will be updated over time. For example, a few months ago I switched from Moq to NSubstitute. Please note that some of the packages are mine.

Development

  • DateTimeProvider
  • FluentResults
  • Serilog.AspNetCore
  • Serilog.Enrichers.Environment
  • Serilog.Enrichers.Thread
  • Serilog.Extras
  • Serilog.Settings.Configuration
  • Serilog.SInks.Seq
  • System.IO.Abstractions
  • Throw

Testing

  • bUnit
  • FluentAssertions
  • NSubstitute

Development crib sheet for interviews

This article was written ahead of doing some interviews a while back. These are some of the development principles you should probably be familiar with that may come up at an interview. This is obviously not an exhaustive list or comprehensive guide.

SOLID Principles

Principle Description Purpose / Meaning Example
Single Responsibility A class should only have one responsibility in an application To reduce complexity and minimise breaking changes GetData()
GetDataAndOutput()
Open/Closed A class is open for extension but closed for modification This means that a class should be extendable without needing to modify the original class. Given a method such as TotalArea(IList<IShape> shapes), logic within shouldn't have specific area calculating logic but rather should call an GetArea() method of the IShape-compliant object.
Liskov Substitution Any derived class should be usable where only a base class is required without the called method needing any awareness of the derived class Any derived class can be passed as if it was the base class and the called method has no need to be aware of this. Given:
BaseClass()
DerivedClass() : BaseClass
CalledMethod(BaseClass param)

Then:
CalledMethod should take any instance of a BaseClass or DerivedClass and treat them both as BaseClass
Interface Segregation Specific interfaces should be used in preference to more generic ones A consumer of an interface should not be forced to implement a method they don't use Instead of just IVehicle, also have interfaces like ICar, IBike, IVan, etc… which inherit from IVehicle
Dependency Inversion Depend on abstractions instead of concrete elements A method or class should not be tightly coupled to another class GetData(IDbConnection conn)
GetData(SqlConnection conn)

DRY

This means Don't Repeat Yourself and simply means that, wherever possible, code should not be repeated. If code is repeating, consider it for extraction to another method, class or even library (such as a NuGet package).

Boxing and Unboxing

This is the process of putting a typed object (e.g. an int) into an object type (boxing) and the reverse process of extracting (or unboxing) and (e.g. an int) from an object. This doesn't do conversion so, if an int was boxed, you can't unbox a short.

Middleware

This provides a bridge to functionality that isn't part of the application or the operating system. In .NET, examples include authentication and authorisation. It is usually registered within an application prior to to the main application process being called.

When added as part of a web application pipeline, the order in which it is added is important so, for example placing the UseSwaggerUI() before any request logging middleware means that requests to the Swagger UI won't be logged where as placing it before would mean Swagger requests are also logged.

Test Driven Development (TDD)

The simple description of this is writing tests before writing code. The concept being writing tests for expected behaviour before coding to help deliver expected requirements.

A good walkthrough of a TDD example can be found on YouTube.

Behaviour Driven Development (BDD)

This takes TDD to the next level by using plain English and concrete example to describe the requirements. Using the Gerkin syntak, Given… When… Then… statements are written to describe the expected behaviour. This can then be directly mapped to unit, integration and, where appropriate, UI tests using tooling such as SpecFlow.

Arrange, Act, Assert

When writing unit tests, typically a three stage approach is used.

Arrange: Setup the “system under test” and any required dependencies using mocks and fakes as required

Act: Perform the action(s) you are testing

Assert: Test the output of running the action(s) under “Act” and confirm the outputs match what is expected

Sometimes sections will be combined such as when using the assert to check for a particularly thrown exception.

Trunk-based development with release branches

I would no longer advocate this approach, in favour of releasing from trunk directly. For my latest thinking, please see my engineering approach article.

There are many ways to develop software and different ways to use branches within that workflow. This article outlines one such approach which is centred around trunk-based development with release branches to get code out to production.

This is not true continuous delivery but does allow a good balance to be achieved between continuous integration and planned deliveries, particularly for teams or projects without the automated testing in place to support a true CI/CD pipeline.

For the purposes of this article, Azure DevOps will be used when a specific platform is referenced regarding version control and deployment pipelines.

This article will be updated (and republished if appropriate) as the process evolves. It is being published now as a starting point and to share ideas.

Environments

For this article, the assumption is that four environments are in play; local development, an integration environment and a blue/green staging and production environment. For the most part and for the purposes of this article, a separate staging and production environment setup will work just the same.

Branches

There will be four types of branches used in this model:

  • main
  • story/*
  • release/*
  • *-on-release-*

Branch Protection

It is recommended that very few people can directly push to main or a release/* branch (anyone should be able to create the latter though). It’s also recommended that self approving PRs are disabled.

Branch Overviews

This section provides a summary of the different branches that are expected within this workflow.

main

The main branch is deployed to the development environment and should be regularly* getting updated with the latest code changes. Any code committed to this branch should be releasable with new features that shouldn’t be available on production protected using feature flagging. This branch should be protected from direct changes.

  • No code should be in a branch outside main that is older than a day – stories or tasks should be small enough to support this
story/ (or task/ or hotfix/ or bug/)

These branches should be short lived and regularly pushed to main (I recommend using pull requests). If working on a story that will take longer than a day it would make sense to have a branch last longer than a day but regular merges to main should still be happening.

release/*

These branches are created from the main branch and will trigger a push to a staging environment. If a problem is found, changes shouldn’t be made directly to the release branch but should be made against main (via a story branch and a pull request) and then a cherry pick pull request made to the release branch. These branches should be protected by direct changes.

Once a release to production is completed successfully, these branches could be deleted but it may be preferred to keep at least some of these branches for references (perhaps the latest three branches) in case an issue occurs.

The naming format of the part after release can be anything as long as it’s unique per release. If daily or less frequent releases are being done, release/yyyyMMdd could be used (e.g. release/20221017). Alternatively, a major.minor.revision approach can be taken.

-on-release-

These branches only come into play when a cherry pick is needed from main to a release branch. The naming format used here is the one that Azure DevOps uses when using the cherry pick functionality (accessible by the three-dot/kebab menu) on a commit’s details page. Once the target branch (i.e. a release/* branch) is selected, the default topic branch name is populated and will look something like 79744d39-on-release-20221017.

Branching Workflow Example

The following example doesn’t highlight origin vs local versions of branches with the assumption being that any local branches are kept up to date. A good approach for this is rebasing the child branch to the parent branch and then, for story branches, using a forced push to origin from local using git push -f. If more than one engineer is working on a story branch, this approach will likely cause issues and isn’t recommended. Whilst multiple engineers working on a story is fine, they probably shouldn’t be working on the same story branch (e.g. one does API, one does UI).

Git branching diagram showing four branches

Code Quality and Testing

For trunk-based development to work well, it’s essential that there is confidence in the quality of the code being merged into the main branch and potentially released at any time.

There are several tools and processes that can be used to improve code quality and improve release confidence including:

  • Pull requests
  • Comprehensive unit testing
  • Test Driven Development
  • Behaviour Driven Development
  • Regression Testing

In the following sections, each of these topics will be covered.

Pull Requests

When code is being merged into the main branch or, when a cherry pick is needed into a release branch via a *-on-release-* branch, a pull request should be used. This gets a second pair of eyes on the code but also should have branch policies in place that trigger a build and run any unit tests before allowing the merge to complete.

Things to look for in a pull request include but are not limited to:

  • Missing or incomplete unit testing
  • Where required, feature flag not in place
  • "Code smells" – code written in a ways that is not good practice and/or may be detrimental to performance, security, etc…
  • Hard coded values
  • Redundant code
  • Non-compliance with SOLID, DRY, etc…
  • Not meeting the requirements of the story, task, etc…
  • Microservice code written in a tightly coupled way
  • Circular references
  • Legal requirements not met e.g. accessibility, cookie compliance, etc…

Some of these may be validly missing from code (e.g. requirement covered by another task, cookie banner is another story, etc…) but remember the code that is approved should be releasable so, if unit testing shouldn’t be missing in most cases and, for incomplete features, a feature flag should likely be in place.

Unit Tests

Having unit tests for your code is a good idea for (at least) two reasons:

  1. You can have confidence when making changes to code that existing functionality hasn’t been broken
  2. Your test confirms your code does what you think it does

Code coverage is used to measure how much of your code is covered by unit testing. A good minimum level is 80% although in some situations, a higher level may be advised.

Test Driven Development (TDD)

The TL;DR version of this is write the tests before the code with the idea being the requirement is written before the implementation. TDD uses a red, green, refactor approach which means a test is written which fails (because there is no implementation), then the implementation is written to make the tests pass and then the code is refactored to get rid of “code smells”, etc… If gaps in the tests are noted, they should be filled, once again using the red, green, refactor approach.

Some YouTube videos on the subject are available from Continuous Delivery and JetBrains.

Behaviour Driven Development (BDD)

BDD is intended to provide clear, human readable, requirements to the engineers that can be translated directly into runnable tests. Doing this using TDD is recommended. If using Visual Studio and .NET, Specflow is one of the most popular choices.

Requirements are written in the form of Given… When… Then… statements to define a requirement. For example:

Given a user is authenticated and authorised
When they visit the landing page
Then they are automatically redirected to the main dashboard

Regression Testing

This kind of testing is about making sure a site as a whole works and is often done with manual testing or automated using tools/frameworks like Selenium or Playwright.

Ideally, especially when running automated testing, these should be run against a clean environment that is spun up for the purposes of testing to guarantee a fixed starting point and to help reduce the brittleness often inherit in automated regression testing. This would include all code, databases and other services. A full dataset may not be required and, if testing against a large dataset, may not be cost or time efficient.

Pipelines

A key part of any CI/CD setup is the pipeline(s) used to build and deploy the application. My preferred approach is a templated one as it helps:

  • Minimise repetition
  • Allow for a single application configuration file
  • Use of scripting languages (e.g. PowerShell Core and Bash) to minimise tying to a specific platform (i.e. Windows or Linux) or pipeline technology (i.e. Azure Pipelines, Octopus, etc…)
  • Use of Helm for Kubernetes deployments