Portfolio

Back to Blog

Dynamic assembly loading

C# .NET Lambda with dynamic assembly loading and versioned exporters using reflection

C# .NETLambda
📦 Multi-channel export

Versioned Data Exporters in .NET Lambda: Dynamic Assembly Loading for Multi-Channel Distribution

Subtitle: How Dynamic Type Loading Enables Breaking Changes Without Downtime for 6+ External Integrations

By David Kwon, Full-Stack Engineer & Cloud Architect
Estimated read time: 8-10 min

Note: Company-specific details have been anonymized. "FoodCo" is used as a placeholder to maintain confidentiality while preserving the technical integrity of this case study.

Table of Contents


Introduction: The Problem Hook

Gone are the days when you could change an API contract and force all consumers to update simultaneously. Modern enterprise integrations require backward compatibility: maintaining old API versions while rolling out new ones, supporting multiple clients with different capabilities, and enabling gradual migrations without breaking production systems.

Here's the challenge: You need to export restaurant menu data to 6+ external systems (POS terminals, kiosks, third-party delivery platforms, voice AI ordering). Each system has different data requirements, response formats, and update frequencies. Worse, these systems upgrade independently—POS v1 can't handle nutritional data, POS v2 requires it. Kiosk v1 expects JSON, v2 demands XML. Third-party delivery APIs change quarterly, breaking integrations.

The naive solution? Deploy separate Lambda functions for each version of each channel: pos-exporter-v1, pos-exporter-v2, kiosk-exporter-v1... Result: 20+ Lambda functions, duplicated code, deployment complexity, and cost explosion. Or the "rewrite everything" approach: force-migrate all clients to v2. Result: weeks of downtime, angry partners, and emergency rollbacks.

This is what I built with the Menu Exporter: a single .NET Lambda function that uses reflection and dynamic assembly loading to instantiate the correct exporter version at runtime, enabling seamless versioning, backward compatibility, and zero-downtime upgrades.

We'll explore the versioned exporter pattern, dynamic type loading in C#, and SNS-driven event processing that powers menu distribution at enterprise scale.

flowchart TD
    A[SNS Event] --> B[Lambda Handler]
    B --> C[Dynamic Load Exporter Version]
    C --> D[Aggregate Data Sources]
    D --> E[Export to Channel]
    E --> F[S3 Upload / API Push]

The Problem in Depth

Summary: Versioning integrations without sprawl is key in enterprise.

Exporting menu data to external systems at enterprise scale introduces several versioning and integration challenges:

1. Breaking Changes Without Downtime: External partners (third-party delivery platforms) update their APIs independently. When a delivery partner changes their JSON schema (e.g., removing a field, adding required field), you can't instantly update all 8,000 stores. Some stores run old POS firmware that can't handle new formats. You need multiple versions in production simultaneously.

2. Code Duplication Without Sprawl: Each channel (POS, Kiosk, delivery platform A, B, C, VoiceAI) has unique data requirements. Each version has unique logic. Naive approach: separate Lambda per version = 6 channels Ă— 3 versions = 18 Lambda functions with 80% duplicated code (connection handling, error retry, logging, caching).

3. Deployment Atomicity: When deploying a new exporter version (e.g., Kiosk v3), you can't afford partial deployment. Either all stores get v3, or none do. But you also can't force-migrate—stores must opt-in gradually. You need runtime version selection based on metadata, not deployment time.

4. Data Aggregation Complexity: Menu exports aren't simple database dumps. They require aggregating data from 5+ sources (product database, pricing service GraphQL API, nutritional data, store hours, regulatory tags), applying channel-specific transformations (POS needs SKU codes, delivery platforms need nutrition per serving size), and caching for performance. This logic is 90% shared across versions—duplicating it is untenable.

Traditional approaches fall short. Separate Lambdas: Code duplication, deployment complexity, high costs. Conditional logic in single Lambda: 1,000+ line if/else chains, unmaintainable. API Gateway routing: Works for HTTP APIs, not SNS events. Feature flags: Runtime toggles don't solve code organization—you still have all versions in one codebase.

ApproachIsolationCode ReuseDeployment SimplicityCost
Separate Lambdas✅❌❌High
Conditional Logic❌✅✅Low
Reflection Loadingâś…âś…âś…Low

Solution Patterns

Summary: Reflection + templates enable versioning without duplication.

Pattern 1: Dynamic Assembly Loading with Reflection

Rather than hardcoding exporter versions or deploying separate functions, the Menu Exporter uses C# reflection to dynamically load exporter classes at runtime based on SNS message metadata.

How it works:

private static IMenuExporter LoadMenuExporter(string typeNameBase, int versionNumber)
{
    // Get path to currently executing assembly
    var path = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);

    // Load assembly (same DLL contains all versions)
    var assembly = Assembly.LoadFrom(Path.Combine(path, "FoodCo.Middleware.Menu.Exporter.Lambda.dll"));

    // Construct type name: e.g., "FoodCo.Middleware.Menu.Exporter.Lambda.POS.V2.MenuExporter"
    var assemblyClass = assembly.GetType($"{typeNameBase}.V{versionNumber}.MenuExporter");

    if (assemblyClass is null)
        throw new Exception($"Error! Could not find Class Object: {typeNameBase}.V{versionNumber}.MenuExporter");

    // Instantiate using parameterless constructor
    return (IMenuExporter)Activator.CreateInstance(assemblyClass);
}

At runtime, SNS event contains version metadata:

public void FunctionHandler(SNSEvent snsEvent, ILambdaContext context)
{
    // Extract metadata from SNS message attributes
    var channelCode = GetAttributeValueFromSnsEvent(snsEvent, "channelCode");  // "pos", "kiosk", etc.
    var menuExporterTypeBase = GetAttributeValueFromSnsEvent(snsEvent, "menuExporterTypeBase");  // Namespace
    var menuVersionNumber = int.Parse(GetAttributeValueFromSnsEvent(snsEvent, "menuVersionNumber"));  // 1, 2, 3

    // Dynamically load correct version
    IMenuExporter exporter = LoadMenuExporter(menuExporterTypeBase, menuVersionNumber);

    // Execute (polymorphism handles version differences)
    exporter.Execute(menuChangeLogId, Config.Parameters, Logger, messageReceiptHandle, menuVersionNumber);
}

đź’ˇ Pros: Single deployment, runtime selection, clean organization.

Cons:

  • Binary size: All versions packaged in single DLL (larger cold starts)
  • Compile-time safety: Typo in type name causes runtime error
  • Reflection overhead: Activator.CreateInstance is slow (but amortized over menu export execution)
  • Testing complexity: Must test all version paths

Outcome: Deployed Kiosk v3 alongside v1/v2 with zero downtime. 1,200 stores migrated gradually over 4 weeks. Old stores continued using v1, new stores onboarded directly to v3.

Pattern 2: Template Method Pattern for Shared Logic

To avoid duplicating code across versions, exporters use Template Method pattern: base class handles common operations (caching, error handling, DB connections), subclasses implement version-specific export logic.

How it works:

public abstract class BaseMenuExporter<ResultType> : IMenuExporter
    where ResultType : BaseMenuExporterResult
{
    public void Execute(int menuChangeLogId, Dictionary<string, string> parameters, ILogger logger, string messageReceiptHandle, int menuVersionNumber)
    {
        // 1. Get menu change record from database
        var menuToExport = MenuExporterDAL.GetExportableMenuChange(menuChangeLogId);

        // 2. Determine if sync or async (some delivery platforms are async)
        var menuType = (menuToExport.ChannelCode == "delivery-platform-a" || menuToExport.ChannelCode == "delivery-platform-b")
            ? "Async"
            : "Sync";

        // 3. Add execution tags for observability (Lumigo tracing)
        SpansContainer.GetInstance().AddExecutionTag("Type", menuType);
        SpansContainer.GetInstance().AddExecutionTag("Channel", menuToExport.ChannelCode);

        if (menuToExport != null && menuToExport.MenuChangeStatusId == MenuChangeStatus.MENU_CHANGE_PUBLISHED)
        {
            try
            {
                logger.LogInformation("Exporting menu using as of {Date}", menuToExport.MenuAsOfDate);

                // 4. Hook for version-specific preprocessing
                MenuExportPreprocess(menuToExport, parameters, logger);

                // 5. Mark export start time
                MenuExporterDAL.SetExportStartTimeStamp(menuChangeLogId);

                // 6. Version-specific export logic (abstract method)
                var result = ExportMenu(menuToExport, parameters, logger, messageReceiptHandle, menuVersionNumber);

                // 7. Store result metadata
                DALs.MenuDAL.SetMenuChangeLogMetadataAsync(menuChangeLogId, JsonConvert.SerializeObject(result));
                MenuExporterDAL.SetExportEndTimeStamp(menuChangeLogId);

                // 8. Hook for version-specific postprocessing
                MenuExportPostprocess(menuToExport, parameters, logger);
            }
            catch (Exception e)
            {
                logger.LogError("MENU_EXPORTER_FAILURE - Menu exporter failed. Error message: {Error}", e);
                MenuExporterDAL.SetMenuExportFailure(menuChangeLogId, e.ToString());
                throw;
            }
        }

        // 9. Delete SNS message from queue (acknowledgment)
        SQSHelper.DeleteMessage(messageReceiptHandle);
    }

    protected abstract ResultType ExportMenu(MenuChangeLog menuChange, Dictionary<string, string> parameters, ILogger logger, string messageReceiptHandle, int menuVersionNumber);
    protected virtual void MenuExportPreprocess(MenuChangeLog menuChange, Dictionary<string, string> parameters, ILogger logger) { }
    protected virtual void MenuExportPostprocess(MenuChangeLog menuChange, Dictionary<string, string> parameters, ILogger logger) { }
}

// Example subclass for POS v2
public class POSMenuExporterV2 : BaseMenuExporter<POSMenuExporterResult>
{
    protected override POSMenuExporterResult ExportMenu(MenuChangeLog menuChange, Dictionary<string, string> parameters, ILogger logger, string messageReceiptHandle, int menuVersionNumber)
    {
        // V2-specific: Fetch menu with nutrition
        var menu = FetchMenuWithNutrition(menuChange.TbRestaurantId, menuChange.MenuAsOfDate);
        var posXml = POSMapper.MapToXmlV2(menu);  // V2-specific mapper

        // Upload to S3
        S3Helper.UploadMenuXml(posXml, menuChange.TbRestaurantId, "pos-v2");

        return new POSMenuExporterResult {
            ProductCount = menu.Products.Count,
            ExportVersion = 2
        };
    }
}

đź’ˇ Pros: DRY shared logic, polymorphism for versions.

Cons:

  • Inheritance coupling: Changes to base class affect all versions
  • Rigidity: Hard to add version-specific steps outside template hooks
  • Hidden behavior: Template method obscures execution flow

Outcome: Adding Kiosk v3 required 150 lines of code (just the differences). Reused 2,500 lines from base class. Deployment: 10 minutes. Zero regressions in v1/v2.

Pattern 3: Data Service Abstraction for Multi-Source Aggregation

Menu exports require data from multiple sources: MySQL database (product catalog), Pricing Service GraphQL API (current prices), Momento cache (performance), AWS Secrets Manager (credentials). Rather than coupling exporters to data sources, the system uses data service abstraction.

How it works:

// Data service initialization (dependency injection via static properties)
if (RefreshParamsIfNecessary(ParamCacheTtl))
{
    // 1. MySQL database connection
    if (!Config.Parameters.TryGetValue("mstr-db-proxy-endpoint", out var dbServerName))
        throw new MissingRequiredParameterException("Missing required parameter: mstr-db-proxy-endpoint");

    DataService.SetMySqlConnectionString(dbServerName, dbName, dbUserId, dbPassword);
    DataService.SetMySqlReadConnectionString(dbServerNameRead, dbName, dbUserId, dbPassword);

    // 2. Momento cache for performance
    if (!Config.Parameters.TryGetValue("momento-api-key", out var momentoKey))
        throw new MissingRequiredParameterException("Missing required parameter: momento-api-key");

    DataService.CacheProvider = new MomentoCacheProvider(momentoCacheName, momentoKey, logger: Logger);

    // 3. Pricing Service GraphQL API with Cognito authentication
    if (!Config.Parameters.TryGetValue("pricing-service-endpoint", out string pricingServiceEndpoint))
        throw new MissingRequiredParameterException("pricing-service-endpoint");

    TbPricingDataService.ServiceEndpoint = pricingServiceEndpoint;
    TbPricingDataService.CredentialProvider = new CognitoUserAccessTokenProvider(
        pricingServiceAuthUserPoolId,
        pricingServiceAuthClientId,
        menuServiceUsername,
        pricingServiceAuthPassword,
        pricingServiceAuthClientSecret
    );
}

Exporters consume via abstraction:

// Get product data (cached, MySQL fallback)
var products = await DataService.GetProducts(storeId);

// Get pricing (GraphQL with Cognito auth, transparent to exporter)
var prices = await TbPricingDataService.GetPricesForStore(storeId);

// Get nutrition (MySQL with read replica)
var nutrition = await DataService.GetNutritionData(productCodes);

đź’ˇ Pros: Resilience, testability, credential security.

Cons:

  • Static state: Data services use static properties (not ideal for testing)
  • Hidden dependencies: Exporters don't declare data dependencies explicitly
  • Service sprawl: 6+ data services (Pricing, Nutrition, Product, etc.)

Outcome: Menu export latency: 2.5 seconds (previously 8 seconds without caching). Database connection pooling reduced MySQL load by 60%.


Implementation Example

In the Menu Exporter, we combined all three patterns to handle a complex scenario: rolling out POS v3 (adds allergen data) to 8,000 stores without breaking v1/v2 clients.

Tech Stack

  • AWS Lambda (.NET 6): Serverless compute
  • AWS SNS: Event-driven triggers for menu changes
  • AWS SQS: Dead-letter queue for failed exports
  • MySQL (RDS Proxy): Product catalog database
  • AWS AppSync (Pricing Service): GraphQL API for pricing
  • Momento: Distributed cache for performance
  • Lumigo: Distributed tracing and observability

Key Decision: Why Reflection over Feature Flags?

We evaluated three approaches for versioning:

Feature flags (LaunchDarkly, AWS AppConfig):

  • âś… Runtime toggles without redeployment
  • ❌ All versions in single codebase (1 giant class)
  • ❌ Doesn't solve code organization
  • ❌ Feature flag sprawl (18 flags for 6 channels Ă— 3 versions)

Separate Lambda functions:

  • âś… Complete isolation
  • ❌ 18 Lambda functions = 18 deployments
  • ❌ Code duplication (80% shared logic)
  • ❌ $0.20/month per function Ă— 18 = $3.60/month base cost (before invocations)

Reflection + dynamic loading:

  • âś… Single Lambda function
  • âś… Clean code organization (namespace per version)
  • âś… Backward compatibility baked in
  • ⚠️ Runtime type loading (potential runtime errors)

We chose reflection for the clean architecture and operational simplicity.

Outcome: Real Numbers

After implementing the versioned exporter pattern:

  • Deployment frequency: 2x per week (previously 1x per month due to fear of breaking changes)
  • Version migration time: 4 weeks gradual rollout (previously "big bang" weekends)
  • Production incidents: 0 (previously 3 rollbacks in 6 months)
  • Code reuse: 85% shared across versions (previously 40% with copy-paste)
  • Lambda cost: $48/month (vs. projected $180/month with 18 separate functions)

Here's the actual SNS event structure that triggers exports:

private static string GetAttributeValueFromSnsEvent(SNSEvent snsEvent, string attributeName)
{
    var snsRecord = snsEvent.Records.First();
    var attributes = snsRecord.Sns.MessageAttributes;
    return attributes[attributeName].Value;
}

// Called with:
// channelCode: "pos" | "kiosk" | "delivery-platform-a" | "delivery-platform-b" | "delivery-platform-c" | "voiceai"
// menuExporterTypeBase: "FoodCo.Middleware.Menu.Exporter.Lambda.POS"
// menuVersionNumber: 1 | 2 | 3
{
  "type": "bar",
  "data": {
    "labels": ["Deployment Freq", "Migration Time (Weeks)", "Incidents", "Code Reuse (%)", "Cost/Month"],
    "datasets": [{
      "label": "Before",
      "data": [0.25, 0.5, 3, 40, 180],
      "backgroundColor": "#e74c3c"
    }, {
      "label": "After",
      "data": [2, 4, 0, 85, 48],
      "backgroundColor": "#2ecc71"
    }]
  },
  "options": {
    "plugins": {"title": {"display": true, "text": "Implementation Impact"}}
  }
}

Lessons Learned

Summary: Test types and monitor size for reflection success.

1. Type Name Typos are Your Enemy

We initially had no compile-time validation for type names. A typo ("POSMenuExporterV2" instead of "POS.V2.MenuExporter") caused runtime errors in production. Takeaway: Add unit tests that verify all version types exist at compile time.

2. Assembly Size Matters for Cold Starts

With all versions in one DLL, assembly size grew to 15MB. Cold starts increased from 800ms to 1.2s. Takeaway: Profile DLL size regularly; consider splitting into core + version-specific assemblies if size explodes.

3. Version Migration Needs Metadata

We initially had no way to track "which stores are on which version." Added a database table mapping storeId → version, queryable via admin UI. Takeaway: Version metadata is as important as the versioned code itself.

4. Backward Compatibility Tests are Critical

We broke v1 twice during v3 development by changing base class behavior. Takeaway: Run full regression test suite against all versions on every deployment, not just the new version.


Takeaways for Developers

Use dynamic assembly loading when:

  • Multiple versions must coexist in production simultaneously
  • Versions share 70%+ logic (worthwhile to share base class)
  • Version selection happens at runtime based on data/events
  • You're comfortable with reflection and runtime type safety trade-offs

Use template method pattern when:

  • Subclasses have slight variations on a standard algorithm
  • You want to enforce certain steps while allowing customization
  • Shared logic is substantial (DRY principle)
  • Inheritance hierarchy won't exceed 2-3 levels

Use separate Lambda functions when:

  • Versions share <50% logic (duplication is manageable)
  • Versions have vastly different performance/memory requirements
  • Team wants complete isolation (separate deployments, monitoring, logs)
  • Cold start latency is not a concern

Avoid reflection when:

  • Type safety is critical (financial transactions, healthcare)
  • Performance is paramount (reflection is slow)
  • Team lacks C#/Java expertise (reflection is advanced)
  • Simpler alternatives exist (feature flags for toggles, not architecture)

What's Your Approach to API Versioning?

Have you built versioned exporters or APIs? What patterns worked for backward compatibility? How do you balance code sharing with version isolation? I'd love to hear your experiences—reach out via LinkedIn or explore more architecture patterns on my portfolio.


Explore More Projects: Pricing Service | Menu Config Service | Product Service | Diablo ETL Framework | Pricing Frontend

GitHub: While this is proprietary enterprise code, I'm happy to discuss architecture decisions and patterns in detail.

Contact: david.kwon@example.com | LinkedIn | Portfolio