<?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: Mirac Aksu</title>
    <description>The latest articles on DEV Community by Mirac Aksu (@nyvarex).</description>
    <link>https://dev.to/nyvarex</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%2F4048292%2F1a7f7a2a-07ae-469c-8ccc-2278b9e6f1b8.JPG</url>
      <title>DEV Community: Mirac Aksu</title>
      <link>https://dev.to/nyvarex</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nyvarex"/>
    <language>en</language>
    <item>
      <title>Modular Monolith First: Why It Beats Microservices on Day One</title>
      <dc:creator>Mirac Aksu</dc:creator>
      <pubDate>Mon, 27 Jul 2026 12:59:02 +0000</pubDate>
      <link>https://dev.to/nyvarex/modular-monolith-first-why-it-beats-microservices-on-day-one-5ap7</link>
      <guid>https://dev.to/nyvarex/modular-monolith-first-why-it-beats-microservices-on-day-one-5ap7</guid>
      <description>&lt;p&gt;Every few weeks someone asks why a new product isn't "built on microservices." My honest answer: on day one, you don't know where your service boundaries are — and microservices make wrong boundaries permanent. A modular monolith makes them cheap to fix.&lt;/p&gt;

&lt;p&gt;This article walks through the reasoning with a &lt;strong&gt;complete working example&lt;/strong&gt; in .NET — a small pet-adoption domain with two modules: compiler-enforced module boundaries, contract-only communication, in-process events at the seams, and architecture tests that fail the build when a boundary is violated.&lt;/p&gt;

&lt;p&gt;All code is here, runnable with one command:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/mrcaksu" rel="noopener noreferrer"&gt;
        mrcaksu
      &lt;/a&gt; / &lt;a href="https://github.com/mrcaksu/modular-monolith-first" rel="noopener noreferrer"&gt;
        modular-monolith-first
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Modular monolith in .NET 8 — enforced module boundaries, contract-only communication, in-process events, architecture tests
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;modular-monolith-first&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A working example of a &lt;strong&gt;modular monolith&lt;/strong&gt; in .NET 8: compiler-enforced module
boundaries, contract-only cross-module communication, in-process domain events
and architecture tests that fail the build when a boundary is violated.&lt;/p&gt;
&lt;p&gt;📄 Article: &lt;em&gt;(dev.to link — added at publication)&lt;/em&gt; · ✍️ Miraç Aksu — Engineering Beyond Code&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Quick start&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;dotnet &lt;span class="pl-c1"&gt;test&lt;/span&gt;                        &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; includes the architecture (boundary) tests&lt;/span&gt;
dotnet run --project src/Api       &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; API on http://localhost:5000 (or shown port)&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Try the cross-module event flow:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;curl localhost:5000/listings
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; pick an id, start an adoption:&lt;/span&gt;
curl -X POST localhost:5000/adoptions \
  -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Content-Type: application/json&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  -d &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{"listingId":"&amp;lt;id&amp;gt;","applicantId":"11111111-1111-1111-1111-111111111111"}&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; complete it — the AdoptionCompleted EVENT closes the listing in the Listings module:&lt;/span&gt;
curl -X POST localhost:5000/adoptions/&lt;span class="pl-k"&gt;&amp;lt;&lt;/span&gt;adoptionId&lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt;/complete
curl localhost:5000/listings       &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; → that listing is now "Closed"&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; try adopting the same listing again → 400 "Listing is not available for adoption."&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What to look at&lt;/h2&gt;

&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;src/Modules/Listings/Listings.Contracts/&lt;/code&gt; —…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/mrcaksu/modular-monolith-first" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  The problem with "microservices first"
&lt;/h2&gt;

&lt;p&gt;Microservices solve an &lt;em&gt;organizational&lt;/em&gt; scaling problem: independent teams deploying independently. They charge for it with distributed-systems costs on every request — network failure modes, eventual consistency, distributed tracing, versioned contracts, infrastructure per service.&lt;/p&gt;

&lt;p&gt;A solo builder or a small team pays those costs while getting the benefit of… independent deployment for teams that don't exist.&lt;/p&gt;

&lt;p&gt;The deeper issue is &lt;strong&gt;boundary discovery&lt;/strong&gt;. Service boundaries should follow domain boundaries, and domain boundaries reveal themselves only after the model meets reality. Move a misplaced boundary between two microservices and you're re-plumbing contracts, pipelines and data ownership. Move it inside a monolith and it's a refactor the compiler supervises.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "modular" actually means
&lt;/h2&gt;

&lt;p&gt;A modular monolith is not "a monolith with folders." It's a single deployable with &lt;strong&gt;enforced&lt;/strong&gt; internal boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each module owns a business capability (&lt;code&gt;Listings&lt;/code&gt;, &lt;code&gt;Adoptions&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A module exposes a tiny public contract; everything else is &lt;code&gt;internal&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Modules never touch each other's data or domain types.&lt;/li&gt;
&lt;li&gt;Cross-module communication goes through the contract or through in-process events — never through a shortcut reference to another module's internals.&lt;/li&gt;
&lt;/ul&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%2Fz72y7xio14cl2q8fxvh8.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%2Fz72y7xio14cl2q8fxvh8.png" alt="Cross-module event flow: Adoptions publishes AdoptionCompleted, the event bus delivers it, Listings closes the listing" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/
  Api/                      ← composition root, the ONLY project that sees everything
  BuildingBlocks/           ← Result, in-process event bus (~20 lines)
  Modules/
    Listings/
      Listings.Contracts/   ← public: IListingReader, ListingSummary
      Listings/             ← internal: Listing, store, handlers
    Adoptions/
      Adoptions.Contracts/  ← public: AdoptionCompleted event
      Adoptions/            ← internal: Adoption, handlers, repository
tests/
  ArchitectureTests/        ← the boundary police
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One namespace convention makes everything downstream trivial:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Modules.X.Contracts&lt;/code&gt; → public surface, anyone may depend on it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Modules.X.Internal&lt;/code&gt; → implementation, only module X itself may touch it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Enforcement, not discipline
&lt;/h2&gt;

&lt;p&gt;Boundaries that rely on code-review discipline die in month two. This example enforces them twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, the compiler.&lt;/strong&gt; Module implementation types are &lt;code&gt;internal&lt;/code&gt;, and modules only hold project references to each other's &lt;code&gt;Contracts&lt;/code&gt; projects. The &lt;code&gt;Adoptions&lt;/code&gt; module &lt;em&gt;cannot&lt;/em&gt; reference a Listings repository — the reference doesn't exist and the type isn't visible:&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="c1"&gt;// Listings.Contracts — the ONLY thing other modules can see&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IListingReader&lt;/span&gt;
&lt;span class="p"&gt;{&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;ListingSummary&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetActiveListing&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;listingId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&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;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ListingSummary&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;PetName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ListingStatus&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Adoptions module — depends on the contract, never on Listings internals&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;StartAdoptionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;IListingReader&lt;/span&gt; &lt;span class="n"&gt;listings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IAdoptionRepository&lt;/span&gt; &lt;span class="n"&gt;adoptions&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;Result&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StartAdoption&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&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;listing&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;listings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetActiveListing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListingId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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;listing&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="n"&gt;listing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;ListingStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Active&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="n"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"Listing is not available for adoption."&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;adoption&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Adoption&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListingId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicantId&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;adoptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adoption&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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;Success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adoption&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="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;&lt;strong&gt;Second, architecture tests.&lt;/strong&gt; Compiler visibility can be bypassed with a lazy &lt;code&gt;public&lt;/code&gt; or a sneaky project reference, so CI runs boundary tests with NetArchTest:&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="n"&gt;Fact&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;void&lt;/span&gt; &lt;span class="nf"&gt;Adoptions_must_not_touch_Listings_internals&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="n"&gt;Types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;InAssembly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Modules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Adoptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AdoptionsModule&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;That&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ResideInNamespaceStartingWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Modules.Adoptions"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ShouldNot&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;HaveDependencyOnAny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Modules.Listings.Internal"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetResult&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;True&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;IsSuccessful&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;A boundary violation is now a failing build, not a code-review debate.&lt;/p&gt;

&lt;p&gt;Small war note from writing these tests: dependency rules match namespaces by prefix. If you forbid &lt;code&gt;"Modules.Listings"&lt;/code&gt; wholesale, you also forbid &lt;code&gt;Modules.Listings.Contracts&lt;/code&gt; — and the test fails for the wrong reason. That's exactly why the &lt;code&gt;Contracts&lt;/code&gt; / &lt;code&gt;Internal&lt;/code&gt; namespace split exists: it makes the allowed and forbidden surfaces &lt;em&gt;prefix-distinguishable&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-module workflows without coupling
&lt;/h2&gt;

&lt;p&gt;When an adoption completes, the listing must close. Calling into &lt;code&gt;Listings&lt;/code&gt; from inside the adoption operation would couple the modules' failure modes. Instead, &lt;code&gt;Adoptions&lt;/code&gt; publishes an event defined in &lt;strong&gt;its own contracts project&lt;/strong&gt;, and &lt;code&gt;Listings&lt;/code&gt; subscribes:&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="c1"&gt;// Adoptions.Contracts — public event, the stable seam&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;AdoptionCompleted&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;AdoptionId&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;ListingId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Adoptions internal — publishes, knows nothing about who listens&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;CompleteAdoptionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IAdoptionRepository&lt;/span&gt; &lt;span class="n"&gt;adoptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IEventBus&lt;/span&gt; &lt;span class="n"&gt;bus&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="nf"&gt;Handle&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;adoptionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&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;adoption&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;adoptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adoptionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                       &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AdoptionNotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adoptionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;adoption&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Complete&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;bus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Publish&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;AdoptionCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adoption&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;adoption&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListingId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ct&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Listings internal — reacts, knows nothing about Adoptions internals&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;CloseListingOnAdoptionCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InMemoryListingStore&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IEventHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AdoptionCompleted&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Task&lt;/span&gt; &lt;span class="nf"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AdoptionCompleted&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListingId&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// Close() is idempotent by design&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompletedTask&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 event bus in the example is deliberately boring — about 20 lines over &lt;code&gt;IServiceScopeFactory&lt;/code&gt;, no libraries. In a production system you'd likely reach for MediatR notifications, and for durability across restarts you'd add the outbox pattern (that's a future article in this series). The architectural point doesn't change with the transport: &lt;strong&gt;the day a module becomes a real service, its in-process contract events become integration events on a broker with the same shape.&lt;/strong&gt; The seam was there all along.&lt;/p&gt;

&lt;p&gt;You can watch the seam work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; src/Api
&lt;span class="c"&gt;# GET  /listings                      → two active listings&lt;/span&gt;
&lt;span class="c"&gt;# POST /adoptions                     → starts an adoption for a listing&lt;/span&gt;
&lt;span class="c"&gt;# POST /adoptions/{id}/complete       → 204; the EVENT closes the listing&lt;/span&gt;
&lt;span class="c"&gt;# GET  /listings                      → that listing is now "Closed"&lt;/span&gt;
&lt;span class="c"&gt;# POST /adoptions (same listing)      → 400 "Listing is not available for adoption."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the payoff: &lt;code&gt;Adoptions&lt;/code&gt; rejected the request using only the public contract — it never looked inside &lt;code&gt;Listings&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a module earns extraction
&lt;/h2&gt;

&lt;p&gt;"Modular monolith first" is not "modular monolith forever." A module earns service extraction when a &lt;strong&gt;measured&lt;/strong&gt; pressure appears:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Divergent scaling&lt;/strong&gt; — one module needs 10× the instances of the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Divergent deployment risk&lt;/strong&gt; — a module changes daily while the rest is stable, and its deploys keep taking everything down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real team boundary&lt;/strong&gt; — a second team owns the module and needs an independent release cadence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hard runtime mismatch&lt;/strong&gt; — the module needs a different stack (a Python ML service beside a .NET core).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are guesses; all are observable. Until one shows up, extraction is cost without benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to use a modular monolith
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The organization already has multiple teams that must deploy independently &lt;strong&gt;today&lt;/strong&gt; — you'd be building the org's bottleneck.&lt;/li&gt;
&lt;li&gt;The domain is genuinely trivial — a CRUD admin panel doesn't need module ceremony; a plain layered app is fine.&lt;/li&gt;
&lt;li&gt;Regulatory isolation forces physically separated services and data stores from day one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What building even this small example taught me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The namespace convention is the whole game.&lt;/strong&gt; &lt;code&gt;Contracts&lt;/code&gt; vs &lt;code&gt;Internal&lt;/code&gt; sounds cosmetic until you write the architecture tests — then it's the difference between a one-line rule and an unenforceable one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make event handlers idempotent from the start.&lt;/strong&gt; &lt;code&gt;Listing.Close()&lt;/code&gt; ignores an already-closed listing. In-process buses can be replaced by at-least-once brokers later; handlers written idempotent today survive that migration untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The composition root is a feature, not a smell.&lt;/strong&gt; Exactly one project (&lt;code&gt;Api&lt;/code&gt;) sees everything. When I ask "who can possibly touch this type?", the answer is checkable in seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Start with a modular monolith: one deployable, compiler-and-CI-enforced boundaries, contract-only communication, events at the seams. You get microservices' most valuable property — clean boundaries — without paying distributed-systems rent before the domain has told you where the boundaries belong. Extract modules only when a measured pressure demands it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full working code:&lt;/strong&gt; &lt;a href="https://github.com/mrcaksu/modular-monolith-first" rel="noopener noreferrer"&gt;https://github.com/mrcaksu/modular-monolith-first&lt;/a&gt; — &lt;code&gt;dotnet test &amp;amp;&amp;amp; dotnet run --project src/Api&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next in this series: enforcing module isolation in .NET in depth — internals, architecture tests, and the tricks that keep boundaries honest.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
