<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Saeed Ghanavat</title>
    <description>The latest articles on DEV Community by Saeed Ghanavat (@ghanavat).</description>
    <link>https://dev.to/ghanavat</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3988051%2F3945b412-2a4a-4c43-8ca3-90a345fee966.jpeg</url>
      <title>DEV Community: Saeed Ghanavat</title>
      <link>https://dev.to/ghanavat</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ghanavat"/>
    <language>en</language>
    <item>
      <title>Lambda Cold Starts Are Not the Whole Story: How I Took a .NET API from ~2s to &lt;1s with SnapStart</title>
      <dc:creator>Saeed Ghanavat</dc:creator>
      <pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ghanavat/lambda-cold-starts-are-not-the-whole-story-how-i-took-a-net-api-from-2s-to-1s-with-snapstart-1jf4</link>
      <guid>https://dev.to/ghanavat/lambda-cold-starts-are-not-the-whole-story-how-i-took-a-net-api-from-2s-to-1s-with-snapstart-1jf4</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5tp3lvk2ab52itrmhgxo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5tp3lvk2ab52itrmhgxo.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With APIs gaining popularity on a daily basis, and with countless ways to host them, the cost of running APIs remains tightly coupled to how you design the architecture of your application.&lt;/p&gt;

&lt;p&gt;If cost is a factor you want to get right, then you need to revisit the architecture of your application and understand the trade-offs you are making. One of my favourite cloud architecture approaches is serverless architecture.&lt;/p&gt;

&lt;p&gt;There are many serverless or serverless-style services on AWS. Some examples are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API Gateway&lt;/li&gt;
&lt;li&gt;Lambda&lt;/li&gt;
&lt;li&gt;DynamoDB&lt;/li&gt;
&lt;li&gt;S3&lt;/li&gt;
&lt;li&gt;SQS&lt;/li&gt;
&lt;li&gt;Fargate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And many more.&lt;/p&gt;

&lt;p&gt;Serverless does &lt;strong&gt;not&lt;/strong&gt; mean there are no servers. Come on 😁. It means there are no servers for &lt;strong&gt;you&lt;/strong&gt; to provision, patch, operate, or babysit. There are obviously servers under the hood. They are just managed by AWS.&lt;/p&gt;

&lt;p&gt;In this article, I am going to focus on a real problem I encountered while building my &lt;a href="https://dev.to/projects/aws-clean-architecture-starter-kit"&gt;AWS Clean Architecture Starter Kit&lt;/a&gt;: &lt;strong&gt;latency with Lambda APIs after inactivity&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is not theory. I tested it, broke it down, got misled by some numbers, corrected the measurements, and eventually got the API below 1 second after inactivity.&lt;/p&gt;

&lt;h1&gt;
  
  
  ECS
&lt;/h1&gt;

&lt;p&gt;One of the best ways to host your application backend is containerisation. I am not going to go deep into how to deploy to ECS in this article. That is not the point here.&lt;/p&gt;

&lt;p&gt;The point is to compare the architectural trade-off between running an API continuously in containers versus running it on demand with Lambda.&lt;/p&gt;

&lt;p&gt;ECS supports two common ways to run containers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ECS on EC2&lt;/li&gt;
&lt;li&gt;ECS on Fargate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s not argue about EC2. We all know EC2 is not serverless. But what about Fargate?&lt;/p&gt;

&lt;p&gt;AWS calls Fargate a serverless compute engine for containers. Fair enough. You do not provision or manage the underlying compute infrastructure. However, from an application architecture point of view, I still do not treat ECS/Fargate as the same kind of serverless experience as Lambda.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because with ECS/Fargate, you still manage important parts of the container architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;task definitions&lt;/li&gt;
&lt;li&gt;ECS services&lt;/li&gt;
&lt;li&gt;desired task count&lt;/li&gt;
&lt;li&gt;deployment behaviour&lt;/li&gt;
&lt;li&gt;scaling policies&lt;/li&gt;
&lt;li&gt;container health checks&lt;/li&gt;
&lt;li&gt;networking&lt;/li&gt;
&lt;li&gt;load balancing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not a bad thing. It is just a different trade-off.&lt;/p&gt;

&lt;p&gt;Applications running in ECS/Fargate generally continue running while your service maintains running tasks. You pay for those running tasks, even if the API is sitting there doing nothing. That may be perfectly acceptable for latency-sensitive APIs, but it is not the same cost model as Lambda.&lt;/p&gt;

&lt;p&gt;And that is as much as I want to say about ECS in this article.&lt;/p&gt;

&lt;h1&gt;
  
  
  Lambda
&lt;/h1&gt;

&lt;p&gt;On the other side, we have Lambda.&lt;/p&gt;

&lt;p&gt;Lambda is much closer to the serverless model most people think about. You do not provision servers, you do not manage the runtime host, and you do not manually scale EC2 instances. Lambda handles the execution environment and scales horizontally as requests come in.&lt;/p&gt;

&lt;p&gt;In my &lt;a href="https://dev.to/projects/aws-clean-architecture-starter-kit"&gt;AWS Clean Architecture Starter Kit&lt;/a&gt;, I chose Lambda for the API hosting model. It is easy to deploy, easy to configure, and easy to connect to API Gateway using CDK.&lt;/p&gt;

&lt;p&gt;But in architecture, nothing is free.&lt;/p&gt;

&lt;p&gt;You always pay somewhere.&lt;/p&gt;

&lt;p&gt;With Lambda, one of the things you need to understand properly is cold start latency.&lt;/p&gt;

&lt;h1&gt;
  
  
  Lambda Cold Starts
&lt;/h1&gt;

&lt;p&gt;One of the important aspects of serverless architecture is how the service behaves when it has not been used for a while.&lt;/p&gt;

&lt;p&gt;With Lambda, AWS may reuse an existing execution environment for later invocations. But after a period of inactivity, or when Lambda needs to scale out, there may be no warm execution environment ready for your request. In that case, Lambda has to prepare one.&lt;/p&gt;

&lt;p&gt;That extra preparation time is what we call a &lt;strong&gt;cold start&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For a normal non-SnapStart Lambda cold start, the rough shape is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cold start path = Init Duration + Duration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a SnapStart-restored Lambda invocation, the rough shape is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SnapStart restored path = Restore Duration + Duration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those fields matter. Client-side tools such as Postman or &lt;code&gt;curl&lt;/code&gt; are useful, but they do not tell the full Lambda story on their own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0lxmip7eor84aw2xusob.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0lxmip7eor84aw2xusob.png" alt="Ghanavats - Lambda vs ECS - Response from Lambda took almost 2 seconds during cold start." width="800" height="576"&gt;&lt;/a&gt;&lt;/p&gt;
Response time in Cold Start



&lt;p&gt;&lt;br&gt;&lt;br&gt;
Cold starts also happen when a function is invoked for the first time or when Lambda has to create a new execution environment during scale-out.&lt;/p&gt;

&lt;p&gt;This is the part you need to understand as an architect:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can your API tolerate that first-request latency?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is no universal answer. It depends on the workload.&lt;/p&gt;

&lt;p&gt;For internal tools, admin APIs, low-traffic apps, event-driven workloads, and async workloads, Lambda can be an excellent fit.&lt;/p&gt;

&lt;p&gt;For APIs that need consistently low p99 latency, especially after idle periods or during scale-out, you need to be more careful.&lt;/p&gt;

&lt;p&gt;In my case, the endpoint was simple. It was a &lt;code&gt;/api/people/{id}&lt;/code&gt; endpoint reading a record from DynamoDB. Nothing crazy. No heavy computation. No massive payload.&lt;/p&gt;

&lt;p&gt;And yet, after inactivity, I was seeing responses around 2 seconds.&lt;/p&gt;

&lt;p&gt;That was not acceptable enough for me to ignore.&lt;/p&gt;

&lt;p&gt;So I investigated.&lt;/p&gt;
&lt;h1&gt;
  
  
  Lambda SnapStart
&lt;/h1&gt;

&lt;p&gt;Lambda SnapStart is designed to improve startup performance by taking a snapshot of the initialised execution environment and restoring from that snapshot later.&lt;/p&gt;

&lt;p&gt;In simple terms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lambda initialises your function.&lt;/li&gt;
&lt;li&gt;Lambda takes a snapshot of memory and disk state.&lt;/li&gt;
&lt;li&gt;Later, instead of doing a normal initialisation from scratch, Lambda restores from that snapshot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That sounds like magic. It is not. It is engineering. And it still has trade-offs.&lt;/p&gt;

&lt;p&gt;SnapStart applies to published versions. This part is critical.&lt;/p&gt;

&lt;p&gt;If you enable SnapStart but keep invoking &lt;code&gt;$LATEST&lt;/code&gt;, you are not testing SnapStart properly. You need to publish a version and invoke that version, usually through an alias such as &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;test&lt;/code&gt;, or &lt;code&gt;prod&lt;/code&gt;.&lt;/p&gt;


&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fghanavats.tech%2Fassets%2Fimages%2Fposts%2Flambda_vs_ecs%2Fsnapstart_publishedversions.png" alt="Ghanavats - Lambda vs ECS - SnapStart is enabled and will be available on next published version" width="798" height="165"&gt;SnapStart is enabled and will be on when next version published



&lt;p&gt;&lt;br&gt;&lt;br&gt;
Once a new version is published and SnapStart optimisation is complete, SnapStart shows as On for the published version.&lt;/p&gt;


&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fghanavats.tech%2Fassets%2Fimages%2Fposts%2Flambda_vs_ecs%2Fsnapstart_on.png" alt="Ghanavats Tech - Lambda vs ECS - SnapStart fully enabled" width="800" height="183"&gt;SnapStart is on



&lt;p&gt;&lt;br&gt;&lt;br&gt;
But here is the mistake I nearly made:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Enabling SnapStart is not the same as optimising for SnapStart.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That distinction matters.&lt;/p&gt;
&lt;h1&gt;
  
  
  Measuring the Right Thing
&lt;/h1&gt;

&lt;p&gt;At first, I tested with Postman. That was useful, but it was also misleading.&lt;/p&gt;

&lt;p&gt;Postman response time includes more than the backend execution time. It can include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;socket initialisation&lt;/li&gt;
&lt;li&gt;DNS lookup&lt;/li&gt;
&lt;li&gt;TCP handshake&lt;/li&gt;
&lt;li&gt;TLS handshake&lt;/li&gt;
&lt;li&gt;waiting for first byte&lt;/li&gt;
&lt;li&gt;download time&lt;/li&gt;
&lt;li&gt;client-side processing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I moved to curl to get cleaner timing breakdowns.&lt;/p&gt;

&lt;p&gt;This is the command I used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"x-api-key: your_api_key"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"namelookup: %{time_namelookup}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;connect: %{time_connect}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;appconnect: %{time_appconnect}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;pretransfer: %{time_pretransfer}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;starttransfer_ttfb: %{time_starttransfer}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;total: %{time_total}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://apigatewayid.execute-api.eu-west-1.amazonaws.com/dev/api/people/person_id"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key field was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl backend wait = starttransfer_ttfb - pretransfer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gave me a better view of how long the client waited after the connection was ready.&lt;/p&gt;

&lt;p&gt;But even &lt;code&gt;curl&lt;/code&gt; was not enough on its own.&lt;/p&gt;

&lt;p&gt;I also checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API Gateway Latency&lt;/li&gt;
&lt;li&gt;API Gateway IntegrationLatency&lt;/li&gt;
&lt;li&gt;Lambda Init Duration&lt;/li&gt;
&lt;li&gt;Lambda Restore Duration&lt;/li&gt;
&lt;li&gt;Lambda Duration&lt;/li&gt;
&lt;li&gt;DynamoDB SuccessfulRequestLatency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one was important.&lt;/p&gt;

&lt;p&gt;DynamoDB was not the problem.&lt;/p&gt;

&lt;p&gt;Across the tests, DynamoDB &lt;code&gt;SuccessfulRequestLatency&lt;/code&gt; stayed low: around 6–14 ms. The slow part was not the DynamoDB table. The slow part was the first SDK/network/runtime path from the restored Lambda environment.&lt;/p&gt;

&lt;h1&gt;
  
  
  Prepare your application for SnapStart
&lt;/h1&gt;

&lt;p&gt;This is the part most people will miss.&lt;/p&gt;

&lt;p&gt;I enabled SnapStart, published the version, wired the alias correctly, waited 5–6 minutes, tested again, and the response was still poor.&lt;/p&gt;

&lt;p&gt;In fact, it was worse in one test.&lt;/p&gt;

&lt;p&gt;That was annoying, but it was also useful.&lt;/p&gt;

&lt;p&gt;It forced me to stop assuming and start measuring properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory Was a Bottleneck
&lt;/h2&gt;

&lt;p&gt;My Lambda initially had 1024 MB memory.&lt;/p&gt;

&lt;p&gt;That was not enough for this .NET Lambda API path.&lt;/p&gt;

&lt;p&gt;I increased memory gradually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1024 MB&lt;/li&gt;
&lt;li&gt;1800 MB&lt;/li&gt;
&lt;li&gt;2048 MB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first jump made a meaningful difference, 1390 ms down from 2230 ms. The jump from 1800 MB to 2048 MB was smaller, but still worth testing. This time slight improvement down to 1200 ms.&lt;/p&gt;

&lt;p&gt;This matters because Lambda memory is tied to CPU allocation. More memory gives your function more CPU capacity, which can help with .NET runtime work, JSON serialisation, AWS SDK work, TLS/signing, and framework overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hello World Warm-Up Was Not Enough
&lt;/h2&gt;

&lt;p&gt;I found out that for .Net applications the JIT compilation and assembly loading time can also be a bottleneck. This was when I learned about runtime hooks, in particular &lt;code&gt;RegisterBeforeSnapshot()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I then tried warming a hello-world endpoint before the snapshot.&lt;/p&gt;

&lt;p&gt;That helped a bit, but it did not solve the real problem.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because hello-world warmed the wrong path.&lt;/p&gt;

&lt;p&gt;My real endpoint was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/api/people/{id}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That endpoint exercised:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ASP.NET Core routing&lt;/li&gt;
&lt;li&gt;minimal API path&lt;/li&gt;
&lt;li&gt;handler resolution&lt;/li&gt;
&lt;li&gt;repository logic&lt;/li&gt;
&lt;li&gt;DynamoDB SDK&lt;/li&gt;
&lt;li&gt;response mapping&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A hello-world endpoint does not warm all of that.&lt;/p&gt;

&lt;p&gt;So I changed the warm-up to target the real endpoint path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAWSLambdaBeforeSnapshotRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HttpRequestMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpMethod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"api/people/00000000-0000-0000-0000-000000000001"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used a fixed diagnostic ID. Do not use random production data for this. Create a harmless diagnostic record that exists only to warm the path.&lt;/p&gt;

&lt;p&gt;Also, do not warm endpoints that write data. That is asking for trouble.&lt;/p&gt;

&lt;p&gt;The point is not to run business logic for its own sake. The point is to warm deterministic and reusable startup paths before Lambda creates the snapshot.&lt;/p&gt;

&lt;h1&gt;
  
  
  Important SnapStart Warning: Uniqueness
&lt;/h1&gt;

&lt;p&gt;SnapStart snapshots initialised state.&lt;/p&gt;

&lt;p&gt;That means you must be careful with anything generated during initialisation.&lt;/p&gt;

&lt;p&gt;Do not generate these before the snapshot if they must be unique after restore:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request IDs&lt;/li&gt;
&lt;li&gt;unique runtime IDs&lt;/li&gt;
&lt;li&gt;secrets&lt;/li&gt;
&lt;li&gt;random seeds&lt;/li&gt;
&lt;li&gt;entropy used for security-sensitive randomness&lt;/li&gt;
&lt;li&gt;per-request timestamps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a value must be unique per request, generate it during the request.&lt;/p&gt;

&lt;p&gt;If a value must be unique per restored execution environment, regenerate it after restore.&lt;/p&gt;

&lt;p&gt;This is not a theoretical concern. It is part of using SnapStart correctly.&lt;/p&gt;

&lt;h1&gt;
  
  
  The Tests
&lt;/h1&gt;

&lt;p&gt;I tested the same endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/people/{id}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used three scenarios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;No SnapStart, no warm-up&lt;/li&gt;
&lt;li&gt;SnapStart enabled, no warm-up&lt;/li&gt;
&lt;li&gt;SnapStart enabled, with real-path warm-up&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All timings below are normalised to milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test #1 — No SnapStart, No Warm-Up
&lt;/h2&gt;

&lt;p&gt;This test gave me the baseline for a normal Lambda cold start without SnapStart.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/people/{id}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mode&lt;/td&gt;
&lt;td&gt;No SnapStart, no warm-up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway IntegrationLatency&lt;/td&gt;
&lt;td&gt;1300 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway Latency&lt;/td&gt;
&lt;td&gt;1300 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway Overhead&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda Init Duration&lt;/td&gt;
&lt;td&gt;478.54 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda Restore Duration&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch REPORT Duration&lt;/td&gt;
&lt;td&gt;646.94 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch Lambda Duration Metric&lt;/td&gt;
&lt;td&gt;632.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB SuccessfulRequestLatency&lt;/td&gt;
&lt;td&gt;13.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl pretransfer&lt;/td&gt;
&lt;td&gt;119.031 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl TTFB&lt;/td&gt;
&lt;td&gt;1469.751 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl total&lt;/td&gt;
&lt;td&gt;1467 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The important number here is the Lambda-side total:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Init Duration + REPORT Duration = 1125.48 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the backend cold path was already above 1 second before adding client-side/network overhead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo46zlst4oaxg3i7il9el.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo46zlst4oaxg3i7il9el.png" alt="Ghanavats - Lambda vs ECS - Log result with no SnapStart and no warm-up" width="799" height="250"&gt;&lt;/a&gt;&lt;/p&gt;
No SnapStart enabled and no warmup mechanism configured





&lt;h2&gt;
  
  
  Test #2 — SnapStart Enabled, No Warm-Up
&lt;/h2&gt;

&lt;p&gt;This was the annoying one.&lt;/p&gt;

&lt;p&gt;SnapStart was enabled. The function was published. The alias was pointing to the published version. CloudWatch showed Restore Duration.&lt;/p&gt;

&lt;p&gt;So SnapStart was working.&lt;/p&gt;

&lt;p&gt;But the API was not faster.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/people/{id}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mode&lt;/td&gt;
&lt;td&gt;SnapStart enabled, no warm-up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway IntegrationLatency&lt;/td&gt;
&lt;td&gt;1700 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway Latency&lt;/td&gt;
&lt;td&gt;1700 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway Overhead&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda Init Duration&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda Restore Duration&lt;/td&gt;
&lt;td&gt;579.74 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch REPORT Duration&lt;/td&gt;
&lt;td&gt;993.37 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch Lambda Duration Metric&lt;/td&gt;
&lt;td&gt;993 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB SuccessfulRequestLatency&lt;/td&gt;
&lt;td&gt;11 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl pretransfer&lt;/td&gt;
&lt;td&gt;101 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl TTFB&lt;/td&gt;
&lt;td&gt;1829 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl total&lt;/td&gt;
&lt;td&gt;1830 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key calculation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Restore Duration + REPORT Duration = 1,573.11 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was worse than the normal non-SnapStart cold start.&lt;/p&gt;

&lt;p&gt;That is the part people need to understand.&lt;/p&gt;

&lt;p&gt;SnapStart was technically working, but the application was not prepared properly for SnapStart. The first restored request still paid expensive first-use costs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvpkcjlmj71oa5yzm74hk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvpkcjlmj71oa5yzm74hk.png" alt="Ghanavats - Lambda vs ECS - Log result with SnapStart enabled and no warm-up" width="800" height="269"&gt;&lt;/a&gt;&lt;/p&gt;
SnapStart enabled and no warmup mechanism configured





&lt;h2&gt;
  
  
  Test #3 — SnapStart Enabled, Real-Path Warm-Up
&lt;/h2&gt;

&lt;p&gt;This is where the result finally became useful.&lt;/p&gt;

&lt;p&gt;I kept SnapStart enabled, but added warm-up for the actual /api/people/{id} path before snapshot creation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/people/{id}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mode&lt;/td&gt;
&lt;td&gt;SnapStart enabled, real-path warm-up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway IntegrationLatency&lt;/td&gt;
&lt;td&gt;733 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway Latency&lt;/td&gt;
&lt;td&gt;733 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway Overhead&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda Init Duration&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda Restore Duration&lt;/td&gt;
&lt;td&gt;452.75 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch REPORT Duration&lt;/td&gt;
&lt;td&gt;169.07 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch Lambda Duration Metric&lt;/td&gt;
&lt;td&gt;169 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB SuccessfulRequestLatency&lt;/td&gt;
&lt;td&gt;6.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl pretransfer&lt;/td&gt;
&lt;td&gt;135 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl TTFB&lt;/td&gt;
&lt;td&gt;896 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl total&lt;/td&gt;
&lt;td&gt;896.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key calculation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Restore Duration + REPORT Duration = 621.82 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the result I was looking for.&lt;/p&gt;

&lt;p&gt;The API response after inactivity dropped below 1 second from &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpgxdxkvvif4g7aquttah.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpgxdxkvvif4g7aquttah.png" alt="Ghanavats - Lambda vs ECS - Log result with SnapStart enabled and with warm-up mechanism configured" width="800" height="253"&gt;&lt;/a&gt;&lt;/p&gt;
SnapStart enabled and with warmup mechanism configured





&lt;h1&gt;
  
  
  All tests aggregated
&lt;/h1&gt;

&lt;p&gt;Here is the full comparison - a bit simplified to help with readability.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;API Gateway Latency&lt;/th&gt;
&lt;th&gt;Init / Restore Duration&lt;/th&gt;
&lt;th&gt;REPORT Duration&lt;/th&gt;
&lt;th&gt;Lambda Duration Metric&lt;/th&gt;
&lt;th&gt;curl pretransfer&lt;/th&gt;
&lt;th&gt;curl TTFB&lt;/th&gt;
&lt;th&gt;curl total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No SnapStart, no warm-up&lt;/td&gt;
&lt;td&gt;1300 ms&lt;/td&gt;
&lt;td&gt;Init: 478.54 ms&lt;/td&gt;
&lt;td&gt;646.94 ms&lt;/td&gt;
&lt;td&gt;633 ms&lt;/td&gt;
&lt;td&gt;119 ms&lt;/td&gt;
&lt;td&gt;1469 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1467 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SnapStart, no warm-up&lt;/td&gt;
&lt;td&gt;1700 ms&lt;/td&gt;
&lt;td&gt;Restore: 579.74 ms&lt;/td&gt;
&lt;td&gt;993.37 ms&lt;/td&gt;
&lt;td&gt;993 ms&lt;/td&gt;
&lt;td&gt;101 ms&lt;/td&gt;
&lt;td&gt;1829 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1830 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SnapStart, real-path warm-up&lt;/td&gt;
&lt;td&gt;733 ms&lt;/td&gt;
&lt;td&gt;Restore: 452.75 ms&lt;/td&gt;
&lt;td&gt;169.07 ms&lt;/td&gt;
&lt;td&gt;169 ms&lt;/td&gt;
&lt;td&gt;135 ms&lt;/td&gt;
&lt;td&gt;896 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;896.8 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The numbers are gathered from CloudWatch logs and API calls results via &lt;code&gt;curl&lt;/code&gt; (last three).&lt;/p&gt;

&lt;h1&gt;
  
  
  Lessons learned
&lt;/h1&gt;

&lt;p&gt;The biggest lesson was this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;SnapStart alone was not enough.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In my test, SnapStart without warm-up was worse than the normal non-SnapStart cold start.&lt;/p&gt;

&lt;p&gt;That does not mean SnapStart is bad. It means I had not prepared the application properly.&lt;/p&gt;

&lt;p&gt;The meaningful improvement came when I warmed the actual request path before the snapshot.&lt;/p&gt;

&lt;p&gt;The latency was mainly coming from the Lambda cold/restore path, first-use application code, SDK/network setup, and framework/runtime costs.&lt;/p&gt;

&lt;h1&gt;
  
  
  Conclusion
&lt;/h1&gt;

&lt;p&gt;Using Lambda to host APIs is exciting, cost-effective, and massively scalable. But it can be a poor choice if you use it blindly and ignore the cold start behaviour.&lt;/p&gt;

&lt;p&gt;The lazy take is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Lambda is bad for latency-sensitive APIs because after idle it can take 2 seconds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That statement is incomplete.&lt;/p&gt;

&lt;p&gt;In my opinion and after all my investigations:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Lambda can be poor for latency-sensitive APIs if you do not understand and optimise the cold/restore path.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In my case, the API did show around 2 seconds after inactivity. But the cause was not simply “Lambda is slow”.&lt;/p&gt;

&lt;p&gt;The real causes were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cold/restore startup cost&lt;/li&gt;
&lt;li&gt;low memory allocation&lt;/li&gt;
&lt;li&gt;first-use .NET/runtime costs&lt;/li&gt;
&lt;li&gt;first-use AWS SDK/network path&lt;/li&gt;
&lt;li&gt;warming the wrong endpoint path&lt;/li&gt;
&lt;li&gt;measuring with client-side tools without separating backend time properly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After enabling SnapStart correctly, increasing memory, and warming the real &lt;code&gt;/api/people/{id}&lt;/code&gt; path before snapshot creation, I got the response to sub-second after inactivity.&lt;/p&gt;

&lt;p&gt;That is the real architectural takeaway.&lt;/p&gt;

&lt;p&gt;Lambda is not the answer for every API. ECS/Fargate is still a very strong option, especially when you need consistently low latency and always-running services.&lt;/p&gt;

&lt;p&gt;But Lambda is also not weak just because cold starts exist.&lt;/p&gt;

&lt;p&gt;The truth is more boring and more useful:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You need to understand the trade-off, measure properly, and optimise the actual path your users hit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No magic. No marketing fluff. Just architecture.&lt;/p&gt;

</description>
      <category>net</category>
      <category>awslambda</category>
      <category>ecs</category>
      <category>lambdasnapstart</category>
    </item>
    <item>
      <title>Integrating DynamoDB into Clean Architecture Wasn't as Simple as I Thought</title>
      <dc:creator>Saeed Ghanavat</dc:creator>
      <pubDate>Thu, 18 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ghanavat/when-clean-architecture-meets-dynamodb-4pap</link>
      <guid>https://dev.to/ghanavat/when-clean-architecture-meets-dynamodb-4pap</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8sg3gtud9srimgv6j21r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8sg3gtud9srimgv6j21r.png" alt="Repository Abstraction Failed with EF Core" width="800" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I thought integrating DynamoDB into my Clean Architecture solution would take an afternoon.&lt;/p&gt;

&lt;p&gt;I already had repository abstractions.&lt;/p&gt;

&lt;p&gt;I already had an EF Core implementation.&lt;/p&gt;

&lt;p&gt;Surely I could swap the persistence layer and keep the rest of the architecture unchanged.&lt;/p&gt;

&lt;p&gt;I was wrong.&lt;/p&gt;

&lt;p&gt;Over the next two days I discovered that the hardest part wasn’t learning DynamoDB.&lt;/p&gt;

&lt;p&gt;It was realising that my existing abstractions were built around assumptions that no longer held true.&lt;/p&gt;

&lt;p&gt;I wanted to build a Clean Architecture solution hosted on AWS Lambda, deployed with CDK, and backed by DynamoDB.&lt;/p&gt;

&lt;h1&gt;
  
  
  Original architecture
&lt;/h1&gt;

&lt;p&gt;What I had in mind was a focused and opinionated Clean Architecture solution built around serverless AWS services.&lt;/p&gt;

&lt;p&gt;The architecture consisted of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS Lambda hosting the API&lt;/li&gt;
&lt;li&gt;API Gateway routing requests&lt;/li&gt;
&lt;li&gt;DynamoDB providing persistence&lt;/li&gt;
&lt;li&gt;AWS CDK managing infrastructure*&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The codebase itself was intended to remain relatively simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Core containing entities and business rules&lt;/li&gt;
&lt;li&gt;Infrastructure handling persistence concerns&lt;/li&gt;
&lt;li&gt;IaC isolated in a dedicated CDK project&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this point I assumed the persistence technology could be swapped without affecting the architecture significantly.&lt;/p&gt;

&lt;p&gt;It was also planned to use my small Repository Pattern package, &lt;a href="https://github.com/ghanavat/Repository" rel="noopener noreferrer"&gt;Ghanavats.Repository&lt;/a&gt;, which I originally created to centralise common persistence functionality and hide implementation details behind simple abstractions.&lt;/p&gt;

&lt;h1&gt;
  
  
  Repository package
&lt;/h1&gt;

&lt;p&gt;The purpose of the package was simple.&lt;/p&gt;

&lt;p&gt;Hide persistence concerns behind common abstractions so the rest of the application remains unaware of the underlying database implementation.&lt;/p&gt;

&lt;p&gt;The package currently provides implementations built around EF Core’s DbContext.&lt;/p&gt;

&lt;p&gt;In theory, this should have made swapping persistence technologies easier.&lt;/p&gt;

&lt;p&gt;In practice, it did not.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why I thought it would work
&lt;/h1&gt;

&lt;p&gt;My first instinct was to extend the existing Repository package and add DynamoDB support behind the same abstractions.&lt;/p&gt;

&lt;p&gt;The idea seemed reasonable.&lt;/p&gt;

&lt;p&gt;I already had abstractions such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IReadRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why not provide another implementation?&lt;/p&gt;

&lt;p&gt;The problem was that the abstractions had been designed around EF Core’s way of querying and working with data.&lt;/p&gt;

&lt;p&gt;I spent a lot of time trying to force the existing abstractions to work.&lt;/p&gt;

&lt;p&gt;The more I pushed in that direction, the more friction I created.&lt;/p&gt;

&lt;p&gt;Some repository methods simply did not map cleanly to DynamoDB operations.&lt;/p&gt;

&lt;p&gt;What looked like a generic abstraction was actually carrying assumptions from EF Core.&lt;/p&gt;

&lt;h1&gt;
  
  
  IAmazonDynamoDB vs DynamoDBContext
&lt;/h1&gt;

&lt;p&gt;This was another point of confusion.&lt;/p&gt;

&lt;p&gt;AWS provides multiple ways to interact with DynamoDB.&lt;/p&gt;

&lt;p&gt;The two options I spent most of my time evaluating were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;IAmazonDynamoDB&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DynamoDBContext&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The low-level interface is exposed through &lt;code&gt;IAmazonDynamoDB&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This API maps very closely to DynamoDB’s underlying operations and gives complete control over requests and responses.&lt;/p&gt;

&lt;p&gt;A simplified example looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PeopleRepository&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IPeopleRepository&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IAmazonDynamoDB&lt;/span&gt; &lt;span class="n"&gt;_dynamoDb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;PeopleRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IAmazonDynamoDB&lt;/span&gt; &lt;span class="n"&gt;dynamoDb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_dynamoDb&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dynamoDb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Person&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetPersonById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;personId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;getItemRequest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;GetItemRequest&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;TableName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Person"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AttributeValue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"PersonId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;AttributeValue&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;personId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"D"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_dynamoDb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getItemRequest&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;personIdMapped&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"PersonId"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;somePropertyMapped&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"SomeProperty"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;person&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;personIdMapped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;somePropertyMapped&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;person&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While powerful, this approach introduces a lot of manual mapping code.&lt;/p&gt;

&lt;p&gt;The alternative is &lt;code&gt;DynamoDBContext&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This high-level interface acts as a wrapper around &lt;code&gt;IAmazonDynamoDB&lt;/code&gt; and handles much of the mapping logic automatically.&lt;/p&gt;

&lt;p&gt;A simplified example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PeopleRepository&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IPeopleRepository&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;DynamoDBContext&lt;/span&gt; &lt;span class="n"&gt;_dbContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;PeopleRepository&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;clientConfig&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;AmazonDynamoDBConfig&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;RegionEndpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RegionEndpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EUWest1&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AmazonDynamoDBClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clientConfig&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;_dbContext&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DynamoDBContextBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithDynamoDBClient&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Person&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetPersonById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;personId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_dbContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoadAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DynamoDbPerson&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;personId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"D"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToDomain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After experimenting with both approaches, &lt;code&gt;DynamoDBContext&lt;/code&gt; felt like the better fit for the starter kit.&lt;/p&gt;

&lt;h1&gt;
  
  
  Mapping challenge
&lt;/h1&gt;

&lt;p&gt;This is where things became interesting.&lt;/p&gt;

&lt;p&gt;With EF Core, I typically keep persistence concerns out of the domain and configure mappings through the Fluent API inside DbContext.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DynamoDBContext&lt;/code&gt; takes a different approach.&lt;/p&gt;

&lt;p&gt;Entities need to be decorated with attributes so DynamoDB knows how to map them correctly.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;DynamoDBTable&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DynamoDBHashKey&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DynamoDBProperty&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adding these attributes directly to my domain entities would have introduced a dependency from the Core layer to the AWS SDK.&lt;/p&gt;

&lt;p&gt;That would violate one of the design principles I wanted the template to enforce.&lt;/p&gt;

&lt;p&gt;The challenge was no longer DynamoDB itself.&lt;/p&gt;

&lt;p&gt;The challenge was introducing DynamoDB without leaking AWS-specific concerns into the &lt;em&gt;Core&lt;/em&gt; layer.&lt;/p&gt;

&lt;h1&gt;
  
  
  Persistence model solution
&lt;/h1&gt;

&lt;p&gt;Eventually I stopped fighting the SDK.&lt;/p&gt;

&lt;p&gt;I decided to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;DynamoDBContext&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Implement a dedicated repository in &lt;em&gt;Infrastructure&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Keep the domain model unchanged&lt;/li&gt;
&lt;li&gt;Introduce a dedicated persistence model for DynamoDB mapping&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rather than forcing DynamoDB to fit my existing Repository package, I introduced a DynamoDB-specific persistence model.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;DynamoDBTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"People"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DynamoDbPerson&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DynamoDBHashKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;PersonId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DynamoDBProperty&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DynamoDBProperty&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DynamoDBProperty&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Phone&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DynamoDBProperty&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DateOfBirth&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allowed the &lt;em&gt;Infrastructure&lt;/em&gt; layer to own all DynamoDB-specific concerns while keeping the &lt;em&gt;Core&lt;/em&gt; project free from AWS dependencies.&lt;/p&gt;

&lt;p&gt;To recreate domain entities from persistence models, I introduced a hydration factory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Person&lt;/span&gt; &lt;span class="nf"&gt;Rehydrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;dateOfBirth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Person&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dateOfBirth&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a mapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PersonMapper&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;extension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DynamoDbPerson&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Person&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;ToDomain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Rehydrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PersonId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DateOfBirth&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final design ended up being much simpler than my original approach.&lt;/p&gt;

&lt;p&gt;Instead of introducing AWS-specific dependencies into the Core layer, I created a dedicated persistence model in Infrastructure and mapped it to the domain entity.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Folz51n5c0znbc2dj4qbc.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Folz51n5c0znbc2dj4qbc.gif" alt="Ghanavats AWS Clean Architecture persistent layer diagram" width="732" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This implementation is now part of the &lt;a href="https://dev.to/projects/aws-clean-architecture-starter-kit"&gt;AWS Clean Architecture Starter Kit&lt;/a&gt; and will likely evolve as I gain more experience with DynamoDB.&lt;/p&gt;

&lt;h1&gt;
  
  
  Lessons learned
&lt;/h1&gt;

&lt;p&gt;Not every abstraction survives a change in persistence technology.&lt;/p&gt;

&lt;p&gt;I expected my repository package to hide DynamoDB in the same way it hid EF Core. In reality, DynamoDB introduces different access patterns and assumptions.&lt;/p&gt;

&lt;p&gt;Clean Architecture is more about dependency direction than specific technologies.&lt;/p&gt;

&lt;p&gt;The biggest challenge was not DynamoDB itself. It was preventing AWS-specific concerns from leaking into the Core layer.&lt;/p&gt;

&lt;p&gt;Sometimes a dedicated implementation is simpler than a generic abstraction.&lt;/p&gt;

&lt;p&gt;I spent a considerable amount of time extending an existing repository package before realising a small, focused DynamoDB implementation was easier to understand and maintain.&lt;/p&gt;

&lt;p&gt;Persistence models and domain models do not always have to be the same thing.&lt;/p&gt;

&lt;p&gt;Introducing a DynamoDB-specific persistence model allowed me to keep the domain clean while still benefiting from &lt;code&gt;DynamoDBContext&lt;/code&gt; and its attribute-based mapping.&lt;/p&gt;

&lt;h1&gt;
  
  
  Conclusion
&lt;/h1&gt;

&lt;p&gt;If I were starting this implementation again today, I would still choose &lt;code&gt;DynamoDBContext&lt;/code&gt; and a dedicated persistence model.&lt;/p&gt;

&lt;p&gt;The solution ended up being much simpler than the repository abstraction I originally tried to preserve.&lt;/p&gt;

&lt;p&gt;Sometimes the best architectural decision is not finding a way to keep an abstraction alive.&lt;/p&gt;

&lt;p&gt;Sometimes it is recognising when it no longer fits the problem you are trying to solve.&lt;/p&gt;

</description>
      <category>dynamodb</category>
      <category>aws</category>
      <category>cleanarchitecture</category>
      <category>repositorypattern</category>
    </item>
    <item>
      <title>Stop Recreating .NET Solutions From Scratch</title>
      <dc:creator>Saeed Ghanavat</dc:creator>
      <pubDate>Sun, 14 Jun 2026 00:57:00 +0000</pubDate>
      <link>https://dev.to/ghanavat/stop-recreating-net-solutions-from-scratch-3957</link>
      <guid>https://dev.to/ghanavat/stop-recreating-net-solutions-from-scratch-3957</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq8qhihyiybahlwqqnlkn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq8qhihyiybahlwqqnlkn.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I have been there myself, so I am not preaching to you.&lt;/p&gt;

&lt;p&gt;I have spent far too much time building new .NET solutions from scratch. Actually, that is not completely true. Most of the time, I was not starting from scratch. I was copying from the first solution I built, or the second one, or the third one, then modifying it again for the fourth or fifth project.&lt;/p&gt;

&lt;p&gt;It worked, but it was painful.&lt;/p&gt;

&lt;p&gt;The worse part is that when you get used to a painful method, it starts to feel normal. Then normal becomes a habit. Then the habit becomes stubborn. Before you know it, you are defending a bad workflow just because you have repeated it enough times.&lt;/p&gt;

&lt;p&gt;That was my mistake.&lt;/p&gt;

&lt;p&gt;Every time I wanted to start a new API or SaaS-style project, I would spend time thinking about the same things again:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;project structure&lt;/li&gt;
&lt;li&gt;configuration&lt;/li&gt;
&lt;li&gt;OpenAPI&lt;/li&gt;
&lt;li&gt;health checks&lt;/li&gt;
&lt;li&gt;authentication and authorisation&lt;/li&gt;
&lt;li&gt;logging&lt;/li&gt;
&lt;li&gt;testing setup&lt;/li&gt;
&lt;li&gt;architecture tests&lt;/li&gt;
&lt;li&gt;maybe .NET Aspire&lt;/li&gt;
&lt;li&gt;all the other boring but important moving parts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these things are useless. They matter. A good solution needs structure.&lt;/p&gt;

&lt;p&gt;But doing the same setup manually again and again is not engineering. It is waste.&lt;/p&gt;

&lt;h1&gt;
  
  
  Copy and paste is not a real template
&lt;/h1&gt;

&lt;p&gt;There is nothing wrong with learning by manually creating a few solutions. In fact, you probably should do that at the beginning. It helps you understand what is actually inside your application.&lt;/p&gt;

&lt;p&gt;The problem starts when you already know your preferred setup, but you still keep copying old projects and cleaning them up by hand.&lt;/p&gt;

&lt;p&gt;That’s why I wrote this article. Believe me, things will go messy if you don’t change your approach.&lt;/p&gt;

&lt;p&gt;You copy an old solution. Then you rename projects. Then you fix namespaces. Then you remove old features. Then you update package versions. Then you change configuration files. Then you realise you forgot something. Then you discover some random leftover name from the previous project.&lt;/p&gt;

&lt;p&gt;That is not a clean starting point. That is a recycled accident.&lt;/p&gt;

&lt;p&gt;A proper template gives you a repeatable way to generate a clean starting point. The .NET CLI uses the Microsoft Template Engine behind &lt;code&gt;dotnet new&lt;/code&gt; to create projects and artifacts based on templates and options.&lt;/p&gt;

&lt;h1&gt;
  
  
  Microsoft already gave us the tool
&lt;/h1&gt;

&lt;p&gt;Microsoft Template Engine is not some obscure trick. It is the system behind &lt;code&gt;dotnet new&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can use existing templates, install template packages, create your own templates, and generate projects from them. Microsoft’s documentation explains that .NET templates can generate projects, files, and resources, and template packages can be installed from NuGet, a NuGet package file, or a file system directory.&lt;/p&gt;

&lt;p&gt;How cool is that?&lt;/p&gt;

&lt;p&gt;That means your standard API setup does not need to live as a half-forgotten GitHub repository that you copy every few months.&lt;/p&gt;

&lt;p&gt;It can become a template.&lt;/p&gt;

&lt;p&gt;For example, instead of manually rebuilding the same solution structure, you could have something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new &lt;span class="nb"&gt;install &lt;/span&gt;MySolution.Templates
dotnet new mysolution-api &lt;span class="nt"&gt;-n&lt;/span&gt; MyNewApi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then your solution is generated with the structure and defaults you already decided are useful.&lt;/p&gt;

&lt;h1&gt;
  
  
  What should go into a good .NET template?
&lt;/h1&gt;

&lt;p&gt;A useful template should not be a dumping ground for every idea you have ever had.&lt;/p&gt;

&lt;p&gt;That is another mistake.&lt;/p&gt;

&lt;p&gt;The goal is not to create a “perfect enterprise solution” before the project even starts. That usually leads to over-engineering. The goal is to create a sensible baseline that saves time without forcing unnecessary complexity.&lt;/p&gt;

&lt;p&gt;For an API template, I would consider including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a clean solution and project structure&lt;/li&gt;
&lt;li&gt;sensible naming&lt;/li&gt;
&lt;li&gt;OpenAPI setup&lt;/li&gt;
&lt;li&gt;health checks&lt;/li&gt;
&lt;li&gt;logging configuration&lt;/li&gt;
&lt;li&gt;basic validation setup&lt;/li&gt;
&lt;li&gt;test projects&lt;/li&gt;
&lt;li&gt;architecture test project&lt;/li&gt;
&lt;li&gt;common configuration files&lt;/li&gt;
&lt;li&gt;optional authentication setup&lt;/li&gt;
&lt;li&gt;optional .NET Aspire support, if it genuinely helps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Microsoft’s template system supports replacing values, including and excluding files, and custom processing during template generation, so you can make templates flexible instead of hard-coding everything.&lt;/p&gt;

&lt;h1&gt;
  
  
  The real benefit is not speed
&lt;/h1&gt;

&lt;p&gt;When you create projects manually, every solution slowly becomes different. One has health checks. One does not. One has better logging. One has outdated package references. One has tests in a different structure. One has old naming conventions.&lt;/p&gt;

&lt;p&gt;That inconsistency becomes expensive later.&lt;/p&gt;

&lt;p&gt;A template forces you to make decisions once, improve them over time, and reuse them properly.&lt;/p&gt;

&lt;p&gt;That is how you turn experience into a tool.&lt;/p&gt;

&lt;h1&gt;
  
  
  Honest tip
&lt;/h1&gt;

&lt;p&gt;Don’t do this too early.&lt;/p&gt;

&lt;p&gt;If you have only built one solution, you probably do not have a template yet. You have an experiment.&lt;/p&gt;

&lt;p&gt;Build manually a few times. Learn what repeats. Learn what is actually useful. Learn what you keep deleting. Then create a template from the parts that survive.&lt;/p&gt;

&lt;h1&gt;
  
  
  How to create a simple .NET solution template
&lt;/h1&gt;

&lt;p&gt;Here is the part that confused me at first.&lt;/p&gt;

&lt;p&gt;Microsoft’s documentation explains custom templates, but the examples are mostly based around item templates, project templates, and packaging templates. That is useful, but it can make the process look more complicated than it needs to be when your actual goal is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I already have a full .NET solution structure I like. I want to reuse it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You do not need to start with a strange folder structure such as working, content, and separate test folders just because a tutorial uses that approach.&lt;/p&gt;

&lt;p&gt;You can start with a real solution.&lt;/p&gt;

&lt;p&gt;Build the solution the way you actually want future projects to look.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TemplateSolution/
│
├── TemplateSolution.sln
│
├── src/
│ ├── TemplateSolution.Api/
│ ├── TemplateSolution.Application/
│ ├── TemplateSolution.Domain/
│ └── TemplateSolution.Infrastructure/
│
├── tests/
│ ├── TemplateSolution.UnitTests/
│ └── TemplateSolution.ArchitectureTests/
│
└── .template.config/
    └── template.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.template.config/template.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That folder should sit at the root of your template, next to the &lt;code&gt;.sln&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;Microsoft’s documentation confirms that the template.json file belongs inside a .template.config folder at the root of the template, and that the template source files can be whatever files and folders you want the template engine to use.&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 1: Create your normal solution
&lt;/h1&gt;

&lt;p&gt;Create your solution exactly how you like it.&lt;/p&gt;

&lt;p&gt;But be careful.&lt;/p&gt;

&lt;p&gt;A template should include repeatable foundations, not every random idea you once thought was clever.&lt;/p&gt;

&lt;p&gt;Also remove junk before turning it into a template, or use &lt;code&gt;.gitignore&lt;/code&gt;. Do not include secrets. Do not include real connection strings. Do not include environment-specific rubbish. That mistake is not “template engineering”; it is negligence.&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 2: Use a clear placeholder name
&lt;/h1&gt;

&lt;p&gt;Pick a source name that appears everywhere in the solution.&lt;/p&gt;

&lt;p&gt;For example &lt;code&gt;TemplateSolution&lt;/code&gt;. Then use it in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TemplateSolution.sln
TemplateSolution.Api
TemplateSolution.Application
TemplateSolution.Domain
TemplateSolution.Infrastructure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters because the template engine can replace the configured &lt;code&gt;sourceName&lt;/code&gt; with the name provided by the user when they run &lt;code&gt;dotnet new&lt;/code&gt;. Microsoft documents &lt;code&gt;sourceName&lt;/code&gt; as the value the template engine searches for in file names and file contents, replacing it with the name passed through &lt;code&gt;-n&lt;/code&gt; or &lt;code&gt;--name&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So later, when someone runs this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new my-api &lt;span class="nt"&gt;-n&lt;/span&gt; Orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template engine can replace &lt;code&gt;TemplateSolution&lt;/code&gt; with &lt;code&gt;Orders&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the bit that saves you from painful renaming.&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 3: Add &lt;code&gt;.template.config/template.json&lt;/code&gt;
&lt;/h1&gt;

&lt;p&gt;At the root of the solution, create this folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.template.config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside it, create this file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;template.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the following in as a starting point. This is more complicated than the one in Microsoft documentation. I needed a few more salt and pepper such as the ability to restore NuGet packages, language name and template type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://json.schemastore.org/template"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"You Name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"classifications"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Common"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"WebApi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"ClassLibrary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"xUnit"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ghanavats AWS Clean Architecture Starter Kit Template"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A deployable .NET Clean Architecture template featuring AWS Lambda, API Gateway, DynamoDB and Infrastructure as Code using AWS CDK."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"identity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ghanavats.CleanArchitecture.Template"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"shortName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ghanavats_aws_cleanarc_starter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C#"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"solution"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sourceName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ghanavats.CleanArchitecture"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"preferNameDirectory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"primaryOutputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ghanavats.CleanArchitecture.sln"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postActions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"actionId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"210D431B-A78B-4D2F-B762-4ED3E3EA9025"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Restore NuGet packages required by this project."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"(!skipRestore)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"continueOnError"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"manualInstructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Run 'dotnet restore'"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;$schema&lt;/code&gt; value is useful because editors that support JSON schemas can provide validation and IntelliSense. This helped me a lot because I was not guessing every property manually. Microsoft also documents this schema field and explains that the full schema is available through JSON Schema Store.&lt;/p&gt;

&lt;p&gt;The most important values are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;shortName
sourceName
identity
name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;shortName&lt;/code&gt; is what you type in the CLI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new my-api-template
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sourceName&lt;/code&gt; is the placeholder name that gets replaced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TemplateSolution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;identity&lt;/code&gt; should be unique.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;name&lt;/code&gt; is the friendly display name.&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 4: Install the template locally
&lt;/h1&gt;

&lt;p&gt;Open a terminal at the root of your solution, where the .sln file and .template.config folder are.&lt;/p&gt;

&lt;p&gt;Then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new &lt;span class="nb"&gt;install&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;dotnet new install&lt;/code&gt; command installs a template package from a path or NuGet package ID. For local development, installing from the current folder is enough.&lt;/p&gt;

&lt;p&gt;Then check that your template is available:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see your template listed with the short name you configured.&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 5: Generate a new solution from your template
&lt;/h1&gt;

&lt;p&gt;Now test it properly.&lt;/p&gt;

&lt;p&gt;Go to a different folder and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new my-api-template &lt;span class="nt"&gt;-n&lt;/span&gt; OrdersService
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That should generate a new solution based on your template.&lt;/p&gt;

&lt;p&gt;Then build it, please. You don’t want a broken code generated off of your template.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Step 6: Improve the template over time
&lt;/h1&gt;

&lt;p&gt;Your first version will probably not be perfect.&lt;/p&gt;

&lt;p&gt;That is fine.&lt;/p&gt;

&lt;p&gt;Use it. Find what feels wrong. Remove unnecessary things. Add missing things. Keep improving the baseline. This is exactly what I did.&lt;/p&gt;

&lt;p&gt;When you change the template and need to reinstall it, you can uninstall and install it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new uninstall ./
dotnet new &lt;span class="nb"&gt;install&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where the value starts to show.&lt;/p&gt;

&lt;p&gt;Instead of improving one project and forgetting to copy the improvement elsewhere, you improve the template. Then every new project starts from a better place.&lt;/p&gt;

&lt;p&gt;That is it.&lt;/p&gt;

&lt;p&gt;The hard part is not the tooling. The hard part is deciding what your standard solution should look like.&lt;/p&gt;

&lt;p&gt;And that is exactly why templates are useful. They force you to stop rebuilding the same foundation again and again, and they turn your repeated decisions into something reusable.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>microsofttemplateeng</category>
      <category>nuget</category>
    </item>
    <item>
      <title>AWS Clean Architecture Starter Kit</title>
      <dc:creator>Saeed Ghanavat</dc:creator>
      <pubDate>Wed, 10 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/ghanavat/aws-clean-architecture-starter-kit-3km3</link>
      <guid>https://dev.to/ghanavat/aws-clean-architecture-starter-kit-3km3</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe6p55xcunlvcu26wgt74.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe6p55xcunlvcu26wgt74.png" alt=" " width="800" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A practical .NET Clean Architecture starter kit for building and deploying serverless APIs on AWS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is It?
&lt;/h2&gt;

&lt;p&gt;The AWS Clean Architecture Starter Kit is a deployable .NET solution template that combines Clean Architecture with AWS serverless services.&lt;/p&gt;

&lt;p&gt;The project provides a starting point for developers who want to build APIs on AWS without spending days configuring infrastructure and boilerplate code.&lt;/p&gt;

&lt;p&gt;The starter kit currently includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clean Architecture template&lt;/li&gt;
&lt;li&gt;ASP.NET Core Minimal API&lt;/li&gt;
&lt;li&gt;AWS Lambda&lt;/li&gt;
&lt;li&gt;Amazon API Gateway&lt;/li&gt;
&lt;li&gt;Amazon DynamoDB&lt;/li&gt;
&lt;li&gt;Amazon CloudWatch Logging&lt;/li&gt;
&lt;li&gt;AWS CDK Infrastructure as Code&lt;/li&gt;
&lt;li&gt;OpenAPI Documentation&lt;/li&gt;
&lt;li&gt;FluentValidation&lt;/li&gt;
&lt;li&gt;Health Checks&lt;/li&gt;
&lt;li&gt;Unit Tests&lt;/li&gt;
&lt;li&gt;Architecture Tests&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I Built It
&lt;/h2&gt;

&lt;p&gt;As a software engineer and AWS Solutions Architect, I found that creating a new AWS-backed API often required significantly more work on infrastructure than on the API itself.&lt;/p&gt;

&lt;p&gt;Packaging Lambda functions, configuring API Gateway, managing permissions, creating infrastructure with CDK, and integrating AWS services all take time.&lt;/p&gt;

&lt;p&gt;I wanted a practical foundation that developers could use as a starting point rather than repeatedly solving the same problems from scratch.&lt;/p&gt;

&lt;p&gt;This starter kit is my attempt to simplify that process while documenting the lessons learned along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Is It For?
&lt;/h2&gt;

&lt;p&gt;This project is intended for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;.NET developers learning AWS&lt;/li&gt;
&lt;li&gt;Developers building serverless applications&lt;/li&gt;
&lt;li&gt;Small teams needing a starting point&lt;/li&gt;
&lt;li&gt;Engineers looking for practical AWS CDK examples&lt;/li&gt;
&lt;li&gt;Developers interested in Clean Architecture&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not intended to be a framework or a complete enterprise platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8t98woa1mff50zipszh9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8t98woa1mff50zipszh9.png" alt="Infrastructure Architecture" width="799" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Design Decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  AWS Lambda Instead of ECS
&lt;/h3&gt;

&lt;p&gt;The starter kit uses AWS Lambda as the primary hosting model.&lt;/p&gt;

&lt;p&gt;Lambda reduces operational overhead, scales automatically, and is often a cost-effective option for APIs with variable traffic patterns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Infrastructure as Code Using AWS CDK
&lt;/h3&gt;

&lt;p&gt;All infrastructure is provisioned using AWS CDK.&lt;/p&gt;

&lt;p&gt;The goal is to allow developers to create, modify and deploy infrastructure without manual AWS Console configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  DynamoDB as the Default Data Store
&lt;/h3&gt;

&lt;p&gt;DynamoDB was selected as the default persistence layer because it integrates naturally with serverless workloads and removes the operational burden of managing database servers.&lt;/p&gt;

&lt;p&gt;V1 intentionally keeps the data model simple by using a single partition key.&lt;/p&gt;

&lt;h3&gt;
  
  
  API Gateway API Keys
&lt;/h3&gt;

&lt;p&gt;API Gateway API Keys provide a lightweight mechanism for controlling access and applying usage plans.&lt;/p&gt;

&lt;p&gt;The API itself does not validate API Keys. Validation is performed by API Gateway before requests are forwarded to AWS Lambda.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Install the template&lt;/li&gt;
&lt;li&gt;Create a new solution&lt;/li&gt;
&lt;li&gt;Configure AWS credentials&lt;/li&gt;
&lt;li&gt;Bootstrap CDK&lt;/li&gt;
&lt;li&gt;Deploy the infrastructure&lt;/li&gt;
&lt;li&gt;Start building&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Detailed setup instructions are available in the GitHub repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source Code
&lt;/h2&gt;

&lt;p&gt;The source code is available on GitHub.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ghanavat/aws-clean-architecture-starter-kit" rel="noopener noreferrer"&gt;AWS Clean Architecture Repository&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Projects
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Ghanavats.ResultPattern
&lt;/h3&gt;

&lt;p&gt;The starter kit includes usage examples based on Ghanavats.ResultPattern.&lt;/p&gt;

&lt;p&gt;ResultPattern provides a lightweight approach to modelling success and failure outcomes without relying on exceptions for expected application behaviour.&lt;/p&gt;

&lt;p&gt;Although many similar libraries exist, I continue to maintain and use this package because it aligns with how I structure application and domain logic.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ghanavat/ResultPattern" rel="noopener noreferrer"&gt;ResultPattern Repository&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Roadmap
&lt;/h2&gt;

&lt;p&gt;Planned improvements include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication and authorisation&lt;/li&gt;
&lt;li&gt;Additional AWS integrations&lt;/li&gt;
&lt;li&gt;CI/CD examples&lt;/li&gt;
&lt;li&gt;Advanced DynamoDB modelling&lt;/li&gt;
&lt;li&gt;Event-driven architecture examples&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Feedback
&lt;/h2&gt;

&lt;p&gt;The project is actively evolving.&lt;/p&gt;

&lt;p&gt;Feedback, suggestions and contributions are welcome.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cleanarchitecture</category>
      <category>iac</category>
      <category>cdk</category>
    </item>
    <item>
      <title>Migrate from AWS WorkMail to Microsoft Exchange and take your emails with you!</title>
      <dc:creator>Saeed Ghanavat</dc:creator>
      <pubDate>Wed, 03 Jun 2026 05:22:42 +0000</pubDate>
      <link>https://dev.to/ghanavat/migrate-from-aws-workmail-to-microsoft-exchange-and-take-your-emails-with-you-1g81</link>
      <guid>https://dev.to/ghanavat/migrate-from-aws-workmail-to-microsoft-exchange-and-take-your-emails-with-you-1g81</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fusplnfiwzabkb3pm88jv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fusplnfiwzabkb3pm88jv.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AWS WorkMail is being retired, with end of support scheduled for &lt;strong&gt;31 March 2027&lt;/strong&gt;. I have been using it as my personal mail server since I began my AWS journey, so this was not something I wanted to ignore until the last minute.&lt;/p&gt;

&lt;p&gt;This article is specific to &lt;strong&gt;AWS WorkMail&lt;/strong&gt; , not Amazon SES. SES is a separate service and, at the time of writing, this retirement announcement is about WorkMail only.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why I migrated
&lt;/h1&gt;

&lt;p&gt;The main reason is obvious: WorkMail is being retired, so I needed to move away from it.&lt;/p&gt;

&lt;p&gt;I also had another reason. In my opinion, AWS WorkMail had a basic UI with basic capabilities. To be fair, that was mostly all I needed. But for my personal setup it was not particularly cheap. I was paying for the WorkMail organisation and each mailbox had its own monthly cost. In total, I was paying around &lt;strong&gt;$30 per month&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When AWS announced the retirement, I wanted to act early. I did not want to leave it until the last minute and then rush the migration under pressure. It also gave me a good reason to review whether I could run my personal email setup in a cheaper and cleaner way.&lt;/p&gt;

&lt;h1&gt;
  
  
  What needed to be planned
&lt;/h1&gt;

&lt;p&gt;The first thing I needed was an alternative.&lt;/p&gt;

&lt;p&gt;After some investigation and comparing the pros and cons of a few options, I decided to use &lt;strong&gt;Microsoft Exchange Online&lt;/strong&gt;. Some may say it is overkill for a personal email setup. I would say it is sustainable, affordable for my use case, feature rich, and easy enough to set up.&lt;/p&gt;

&lt;p&gt;Below is the list I used to plan the migration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Domain ownership verification&lt;/li&gt;
&lt;li&gt;MX record reconfiguration&lt;/li&gt;
&lt;li&gt;Email migration from AWS WorkMail to Exchange Online&lt;/li&gt;
&lt;li&gt;How to reduce cost when creating mailboxes&lt;/li&gt;
&lt;li&gt;How many licensed mailboxes I actually needed&lt;/li&gt;
&lt;li&gt;User downtime risk and how to minimise it&lt;/li&gt;
&lt;li&gt;What to test before and after the cutover&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  DNS and domain considerations
&lt;/h1&gt;

&lt;p&gt;I decided to keep my domain in &lt;strong&gt;AWS Route 53&lt;/strong&gt;. I also avoided removing any existing DNS records too early because I did not want to risk losing emails in flight.&lt;/p&gt;

&lt;p&gt;After setting up the new Microsoft Exchange Online subscription, I was able to verify ownership of my domain fairly quickly. This gave me room to manoeuvre. I could create the mailboxes, aliases, and shared mailboxes I needed before touching the MX records.&lt;/p&gt;

&lt;p&gt;At the beginning, the DNS and domain side was straightforward. I only had to revisit it properly once I was ready to complete the cutover and point the MX records to Exchange Online.&lt;/p&gt;

&lt;h1&gt;
  
  
  Mailbox/user considerations
&lt;/h1&gt;

&lt;p&gt;I decided to use only two licensed users: my account and my wife’s account. Only the two of us needed separate personal mailboxes.&lt;/p&gt;

&lt;p&gt;For everything else, I used &lt;strong&gt;aliases&lt;/strong&gt; and &lt;strong&gt;shared mailboxes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I created a handful of aliases under my own user mailbox. I like doing this because it gives me a meaningful separation of email addresses without creating unnecessary paid mailboxes.&lt;/p&gt;

&lt;p&gt;For emails that both of us need to manage, I created a few shared mailboxes and gave both of us access to read and manage them. When you create shared mailboxes in Exchange Online, Microsoft 365 creates the necessary underlying account objects. In my case, that was not an issue because the shared mailboxes were tied to the main licensed accounts for access and management.&lt;/p&gt;

&lt;p&gt;To give a better picture of what I ended up with, this is a simplified version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My licensed account 

&lt;ul&gt;
&lt;li&gt;Alias 1&lt;/li&gt;
&lt;li&gt;Alias 2&lt;/li&gt;
&lt;li&gt;Alias 3&lt;/li&gt;
&lt;li&gt;Access to all shared mailboxes&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;My wife’s licensed account 

&lt;ul&gt;
&lt;li&gt;Access to all shared mailboxes&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Shared mailbox 1&lt;/li&gt;
&lt;li&gt;Shared mailbox 2&lt;/li&gt;
&lt;li&gt;Shared mailbox 3&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This reduced the number of paid accounts and made the setup easier to manage.&lt;/p&gt;

&lt;h1&gt;
  
  
  Cutover approach
&lt;/h1&gt;

&lt;p&gt;The best approach I could think of was to set up all the mailboxes, shared mailboxes, and aliases I needed first, then leave AWS WorkMail in place until the end of the process. I wanted to continue receiving emails for as long as possible before changing the MX records.&lt;/p&gt;

&lt;p&gt;I then investigated how to migrate my existing emails from AWS WorkMail to Exchange Online. After around two hours of reading, testing, and trial and error, I landed on the migration tool inside the &lt;strong&gt;Exchange Admin Center&lt;/strong&gt;. The migration type I needed was &lt;strong&gt;IMAP migration&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgt6wspljc31qz9h12guq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgt6wspljc31qz9h12guq.png" alt="Exchange migration screen" width="800" height="723"&gt;&lt;/a&gt; &lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwzsmj726tpwhoi7e68a6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwzsmj726tpwhoi7e68a6.png" alt="Exchange IMAP migration screen" width="732" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Exchange Admin Center has a migration batch tool that lets you migrate mailboxes from different sources. In my case, I only needed IMAP migration.&lt;/p&gt;

&lt;p&gt;Setting up access to my AWS WorkMail mailboxes from the Exchange IMAP migration tool took longer than I first expected. The tool requires a CSV file with the mailbox details. Yes, this includes passwords in plain text. It is what it is, but treat that file carefully and delete it when you no longer need it.&lt;/p&gt;

&lt;p&gt;To save time and reduce pain, the CSV format must look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;EmailAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;UserName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;Password&lt;/span&gt;
&lt;span class="k"&gt;myuser&lt;/span&gt;&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="kp"&gt;@mydomain&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;co&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;uk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;myuser&lt;/span&gt;&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="kp"&gt;@mydomain&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;co&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;uk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;mysuperstrongpassword&lt;/span&gt;
&lt;span class="k"&gt;myuser&lt;/span&gt;&lt;span class="mf"&gt;2&lt;/span&gt;&lt;span class="kp"&gt;@mydomain&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;co&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;uk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;myuser&lt;/span&gt;&lt;span class="mf"&gt;2&lt;/span&gt;&lt;span class="kp"&gt;@mydomain&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;co&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;uk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;myevenstrongerpassword&lt;/span&gt;
&lt;span class="k"&gt;myuser&lt;/span&gt;&lt;span class="mf"&gt;3&lt;/span&gt;&lt;span class="kp"&gt;@mydomain&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;co&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;uk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;myuser&lt;/span&gt;&lt;span class="mf"&gt;3&lt;/span&gt;&lt;span class="kp"&gt;@mydomain&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;co&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;uk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;someonessuperstrongpassword&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note that the values under &lt;strong&gt;EmailAddress&lt;/strong&gt; and &lt;strong&gt;UserName&lt;/strong&gt; are the same in this example. AWS WorkMail did not like me using only the username. It expected the full email address as the username.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After uploading the CSV file with all the source mailboxes listed, I was able to migrate and sync the emails from AWS WorkMail into the new Exchange Online mailboxes. The migration report showed the number of synced emails from the source. The migration brought across the inbox, sent items, and my custom folders.&lt;/p&gt;

&lt;p&gt;Shared mailboxes can be added to the Outlook client app and appear under the main account. For Mac users, I had to add the shared mailbox through the legacy Outlook UI first, then switch back to the new UI. Adding the shared mailbox directly from the new Outlook UI did not work for me.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;At this stage I had not reconfigured the MX records with the new Exchange Online details. Everything was still untouched in AWS WorkMail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  Testing before and after
&lt;/h1&gt;

&lt;p&gt;Once the migration was done, I went back to the Exchange Admin Center and continued the email setup. Microsoft gave me the DNS records I needed to add, including the new MX record.&lt;/p&gt;

&lt;p&gt;In AWS Route 53, I had to delete the existing MX record because editing it directly did not work cleanly for me. I did this quickly and carefully. In less than 10 minutes, the new records were in place and AWS WorkMail was no longer receiving new inbound email for the domain.&lt;/p&gt;

&lt;p&gt;I then logged into my own mailbox and did a sanity check against the migrated data. To my surprise, because I had never done this before, things looked accurate. The migrated emails were there and the folders looked right.&lt;/p&gt;

&lt;p&gt;For testing, I would recommend checking at least the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Send an email from an external address to the new mailbox&lt;/li&gt;
&lt;li&gt;Send an email from Exchange Online to an external address&lt;/li&gt;
&lt;li&gt;Test replies, not just new messages&lt;/li&gt;
&lt;li&gt;Check aliases&lt;/li&gt;
&lt;li&gt;Check shared mailboxes&lt;/li&gt;
&lt;li&gt;Check Outlook desktop/mobile client access&lt;/li&gt;
&lt;li&gt;Check that old WorkMail mailboxes are no longer receiving new messages after the MX cutover&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Mistakes or risks to avoid
&lt;/h1&gt;

&lt;p&gt;I made a funny and expensive mistake when I first set up my email server in AWS WorkMail. I created a separate user account and a completely separate mailbox for almost every purpose.&lt;/p&gt;

&lt;p&gt;I had my personal one, then another one for my wife, then another for family stuff, then another for something else, and then repeated that a few more times.&lt;/p&gt;

&lt;p&gt;That approach is fine for millionaires who are not bothered about spending an extra $15 or $20 per month for a personal email server. For me, however, it was expensive. I am not financially free yet!&lt;/p&gt;

&lt;p&gt;I did not make the same mistake again in my new Exchange Online subscription. I became best friends with aliases, shared mailboxes, and Outlook rules to move emails into the right folders. It is cheaper, easier to maintain, and has less configuration overhead.&lt;/p&gt;

&lt;p&gt;Another risk is treating the migration like it is just a technical copy job. It is not. Email is boring until it breaks. If you are doing this for a business, even a small one, you need to think about downtime, DNS propagation, user access, shared mailboxes, and rollback options.&lt;/p&gt;

&lt;h1&gt;
  
  
  IMAP migration under the hood
&lt;/h1&gt;

&lt;p&gt;An IMAP migration is best understood as a controlled mailbox copy.&lt;/p&gt;

&lt;p&gt;Exchange Online does not magically &lt;em&gt;move&lt;/em&gt; the mailbox from the source server. Instead, Microsoft 365 connects to the source mail server using IMAP, authenticates against each mailbox, reads the available mail folders, pulls the messages from those folders, and then writes them into the target Exchange Online mailbox.&lt;/p&gt;

&lt;p&gt;For this to work, the target users and mailboxes must already exist in Microsoft 365 before the migration starts. The migration endpoint stores the source IMAP server connection details, and the migration batch uses the CSV mapping file to know which source mailbox maps to which target mailbox.&lt;/p&gt;

&lt;p&gt;It is a simple and direct server-to-server copy process.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One important limitation: IMAP migration is mainly about email messages and mail folders. It does not migrate everything that exists in a full groupware mailbox. Contacts, calendar items, and tasks are not migrated through IMAP, so those need to be handled separately if they matter to you. This is easy to overlook because users often think of a mailbox as email, calendar, contacts, and settings all together. IMAP does not cover all of that.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fklqosrzfen452gdbe3d2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fklqosrzfen452gdbe3d2.png" alt=" " width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Final thoughts
&lt;/h1&gt;

&lt;p&gt;I found the process fairly straightforward, apart from the trial and error around the mailbox CSV file. If I had known the correct CSV format earlier, the whole process would have been much simpler.&lt;/p&gt;

&lt;p&gt;For my small personal setup, I did not need a separate archive or backup workflow. I also avoided adding anything unnecessary around SES archiving or journaling because it would have added cost and complexity I did not need.&lt;/p&gt;

&lt;p&gt;The key point is this: &lt;strong&gt;IMAP migration is not just a button-click task&lt;/strong&gt;. It is a staged copy-and-cutover process. The mailbox data copy, DNS changes, user communication, Outlook reconfiguration, and final verification all need to be planned together.&lt;/p&gt;

&lt;p&gt;The technical migration may be IMAP, but the real success factor is controlling disruption for the users.&lt;/p&gt;

&lt;p&gt;If you encounter issues during the sync process, take a look at the &lt;a href="https://learn.microsoft.com/en-us/troubleshoot/exchange/move-or-migrate-mailboxes/troubleshoot-issues-with-imap-mailbox-migration" rel="noopener noreferrer"&gt;Microsoft troubleshooting guide for IMAP mailbox migration&lt;/a&gt;.&lt;/p&gt;

&lt;h1&gt;
  
  
  What I would do differently next time
&lt;/h1&gt;

&lt;p&gt;Next time, I would prepare the CSV file earlier and test it with one mailbox first. That would have saved me time and removed most of the guesswork.&lt;/p&gt;

&lt;p&gt;I would also create a simple mailbox inventory before starting. Nothing complicated, just a small table showing each source mailbox, the target mailbox, whether it is licensed or shared, and whether it has aliases. For a personal migration this may feel unnecessary, but even a small checklist helps when DNS, passwords, mailboxes, and clients are all involved.&lt;/p&gt;

&lt;p&gt;I would reduce the DNS TTL in advance where possible, then wait before doing the MX cutover. That gives you a better chance of a quicker and cleaner switch when you are ready.&lt;/p&gt;

&lt;p&gt;For a larger or business-critical migration, I would also keep the migration batch running a little longer after the MX cutover and perform a final sync/validation before fully shutting down the old system. I got away with a simple process because my setup was small. I would not treat a business migration casually.&lt;/p&gt;

&lt;p&gt;The biggest lesson is simple: do the boring preparation properly. The migration tool does the copy, but planning avoids the panic.&lt;/p&gt;

</description>
      <category>awsworkmail</category>
      <category>microsoftexchange</category>
      <category>emailserver</category>
    </item>
  </channel>
</rss>
