Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
149503 stories
·
33 followers

Microsoft Agent Framework: Using Agents as Function Tools

1 Share

 

In last week blog post, we saw how to extend an AI agents intelligence using function tools.

In this post, we see how you build AI agents and advertise them as function tools to other agents .  There are a few reasons why you might want to do this.

In this blog post we cover:

  • why implement an agent as function tool
  • how to implement an agent as function tool
  • creating a nutrition agent that can find protein rich foods
  • extending a personal trainer ai agent use the nutritionist agent
  • implementing the nutrition agent as a function tool
  • testing personal trainer ai agent new capabilities

 

A video demo and full code examples are included.

~

Why Implement an Agent as Function Tool

You might want to use this pattern to isolate behaviours and responsibilities during your AI agent development.

With this approach, you can build multiple agents, use them individually, or augment an existing AI agent’s capability with behaviours from other agents.

Applying this approach removes the risk of developing monolithic agents.

Creating discrete agents and advertising them as function tools makes it easier to design, build, maintain, and observe your AI agents.

~

How to Implement an Agent as Function Tool

To implement an agent as function tool you use the AsAIFunction method and call this from your parent agent.

You can see an example of this here:

AIAgent agent = new OpenAIClient(apiKey)
           .GetChatClient(model)
           .CreateAIAgent(instructions: "You are a personal trainer." +
                                        "You can answer questions related to health, fitness and 5x5 stronglift. " +
                                        "You should only use function tools in the PersonalTrainerAgent or NutritionAgent.",
                          name: "IronMind AI", null,
                          tools: [AIFunctionFactory.Create(PersonalTrainerAgent.GetNextAvailableDate),
                                 AIFunctionFactory.Create(PersonalTrainerAgent.CancelAppointment),
                                 AIFunctionFactory.Create(PersonalTrainerAgent.BookAppointment),
                                 AIFunctionFactory.Create(PersonalTrainerAgent.ListBookedAppointments),
                                 YOUR-ADDITIONAL-AGENT.AsAIFunction()]
                         );

 

Let’s see how we can apply this pattern to extend our original Iron Mind AI personal trainer agent.

~

An Example – Extending the Iron Mind AI Personal Trainer Agent

In an earlier blog post, we created 4 function tools and made them available to the Iron Mind AI personal trainer agent.

To recap, these were:

  • Get next available date for a booking
  • Make a booking
  • Cancel a booking
  • Get list of existing bookings

 

We want to extend the Iron Mind AI personal trainer agent to handle more than booking sessions.  We want to introduce function tools that let people:

  • Search and filter for high protein recipes
  • Learn how to cook and prepare each recipe
  • Examine key nutrients and macros

 

We can do this by creating a new Nutritionist Agent.  Then, by using the .AsAIFunction() method, make this available to our Personal Trainer Agent as an additional function tool.

Before we do that though, a few things must be done:

  • Obtain recipe data
  • Create a way to search for recipes
  • Map to objects for use by our nutrition agent

 

Lets look at these now.

Obtaining Recipe Data

We need a data source with recipes.  There are plenty online and found plenty at: https://www.jamieoliver.com/search/.

We also need objects to represent the data.

  • Recipe
  • NutritionInfo

 

The definitions for these are as follows:

public class Recipe
 {
     public string Title { get; set; } = string.Empty;
     public string Url { get; set; } = string.Empty;
     public List<string> Ingredients { get; set; } = new();
     public List<string> Instructions { get; set; } = new();
     public NutritionInfo? Nutrition { get; set; }
     public string PrepTime { get; set; } = string.Empty;
     public string CookTime { get; set; } = string.Empty;
     public int Servings { get; set; }
 }
 
public class NutritionInfo
 {
     public int Calories { get; set; }
     public int ProteinGrams { get; set; }
     public int CarbsGrams { get; set; }
     public int FatGrams { get; set; }
 }

 

Now we need a way to fetch and serialise data from the source to the above objects.

~

Creating a Nutrition Service to Search for Data

We can create a service class to encapsulate all core search, scraping and parsing logic.

We’ll call this NutritionService.

Rather than manually implement this, I used Claude to create most of this service class then manually removed code that was overkill.

The key methods in the NutritionService are:

  • SearchRecipesAsync – searches for recipes that match a search term
  • ScrapeRecipeAsync – fetches details for a recipe

 

Some utility methods are also included to help with scraping and parsing recipe HTML data to the Recipe and NutritionInfo objects we created earlier.

A completed definition for the NutritionService is provided:

public class NutritionService
{
    private readonly HttpClient _httpClient;
    private readonly HtmlWeb _htmlWeb;
    private readonly int _delayMs;

    public NutritionService(int delayMs = 1500)
    {
        _delayMs = delayMs;
        _httpClient = new HttpClient();
        _httpClient.DefaultRequestHeaders.Add("User-Agent",
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) RecipeBot/1.0");
        _htmlWeb = new HtmlWeb();
    }

    /// <summary>
    /// Searches Jamie Oliver's site and returns recipe URLs
    /// </summary>
    public async Task<List<string>> SearchRecipesAsync(string searchTerm)
    {
        var urls = new List<string>();
        var searchUrl = $"https://www.jamieoliver.com/search/{Uri.EscapeDataString(searchTerm)}";

        await Task.Delay(_delayMs);
        var doc = await _htmlWeb.LoadFromWebAsync(searchUrl);

        var linkNodes = doc.DocumentNode.SelectNodes("//a[contains(@href,'/recipes/')]");

        if (linkNodes != null)
        {
            foreach (var linkNode in linkNodes)
            {
                var href = linkNode.GetAttributeValue("href", "");
                if (!string.IsNullOrWhiteSpace(href))
                {
                    if (href.StartsWith("/"))
                        href = $"https://www.jamieoliver.com{href}";

                    if (IsRecipeUrl(href) && !urls.Contains(href))
                        urls.Add(href);
                }
            }
        }

        Console.WriteLine($"Found {urls.Count} recipe URLs for search term '{searchTerm}'.");

        return urls;
    }

    /// <summary>
    /// Scrapes a single recipe from URL
    /// </summary>
    public async Task<Recipe?> ScrapeRecipeAsync(string url)
    {
        await Task.Delay(_delayMs);
        var doc = await _htmlWeb.LoadFromWebAsync(url);

        // Try JSON-LD first, fallback to HTML
        return ParseJsonLdRecipe(doc, url) ?? ParseHtmlRecipe(doc, url);
    }

    /// <summary>
    /// Filters recipes by minimum protein content
    /// </summary>
    public List<Recipe> FilterByProtein(List<Recipe> recipes, int minProteinGrams)
    {
        return recipes
            .Where(r => r.Nutrition != null && r.Nutrition.ProteinGrams >= minProteinGrams)
            .OrderByDescending(r => r.Nutrition!.ProteinGrams)
            .ToList();
    }

    #region Parsing Helpers

    private Recipe? ParseJsonLdRecipe(HtmlDocument doc, string url)
    {
        var scriptNodes = doc.DocumentNode.SelectNodes("//script[@type='application/ld+json']");
        if (scriptNodes == null) return null;

        foreach (var scriptNode in scriptNodes)
        {
            var json = scriptNode.InnerText;
            if (!json.Contains("\"@type\":\"Recipe\"")) continue;

            try
            {
                using var jsonDoc = JsonDocument.Parse(json);
                var root = jsonDoc.RootElement;
                var recipeElement = root.ValueKind == JsonValueKind.Array ? root[0] : root;

                var recipe = new Recipe
                {
                    Url = url,
                    Title = GetJsonString(recipeElement, "name"),
                    PrepTime = GetJsonString(recipeElement, "prepTime"),
                    CookTime = GetJsonString(recipeElement, "cookTime")
                };

                // Servings
                if (recipeElement.TryGetProperty("recipeYield", out var yieldElement))
                {
                    recipe.Servings = yieldElement.ValueKind == JsonValueKind.Number
                        ? yieldElement.GetInt32()
                        : ExtractNumber(yieldElement.GetString() ?? "");
                }

                // Ingredients
                if (recipeElement.TryGetProperty("recipeIngredient", out var ingredients))
                {
                    foreach (var ingredient in ingredients.EnumerateArray())
                        recipe.Ingredients.Add(ingredient.GetString() ?? "");
                }

                // Instructions
                if (recipeElement.TryGetProperty("recipeInstructions", out var instructions))
                {
                    int step = 1;
                    foreach (var instruction in instructions.EnumerateArray())
                    {
                        string text = instruction.ValueKind == JsonValueKind.Object
                            ? GetJsonString(instruction, "text")
                            : instruction.GetString() ?? "";

                        if (!string.IsNullOrWhiteSpace(text))
                            recipe.Instructions.Add($"{step++}. {text}");
                    }
                }

                // Nutrition
                if (recipeElement.TryGetProperty("nutrition", out var nutrition))
                {
                    recipe.Nutrition = new NutritionInfo
                    {
                        Calories = ExtractNumber(GetJsonString(nutrition, "calories")),
                        ProteinGrams = ExtractNumber(GetJsonString(nutrition, "proteinContent")),
                        CarbsGrams = ExtractNumber(GetJsonString(nutrition, "carbohydrateContent")),
                        FatGrams = ExtractNumber(GetJsonString(nutrition, "fatContent"))
                    };
                }

                return recipe;
            }
            catch (JsonException)
            {
                continue;
            }
        }

        return null;
    }

    private Recipe? ParseHtmlRecipe(HtmlDocument doc, string url)
    {
        var recipe = new Recipe { Url = url };

        // Title
        var titleNode = doc.DocumentNode.SelectSingleNode("//h1");
        recipe.Title = CleanText(titleNode?.InnerText ?? "Unknown Recipe");

        // Ingredients
        var ingredientNodes = doc.DocumentNode.SelectNodes(
            "//ul[contains(@class,'ingred')]//li | //div[contains(@class,'ingredient')]//li");

        if (ingredientNodes != null)
        {
            foreach (var node in ingredientNodes)
            {
                var ingredient = CleanText(node.InnerText);
                if (!string.IsNullOrWhiteSpace(ingredient))
                    recipe.Ingredients.Add(ingredient);
            }
        }

        // Instructions
        var instructionNodes = doc.DocumentNode.SelectNodes(
            "//ol[contains(@class,'method')]//li | //div[contains(@class,'method')]//p");

        if (instructionNodes != null)
        {
            int step = 1;
            foreach (var node in instructionNodes)
            {
                var instruction = CleanText(node.InnerText);
                if (!string.IsNullOrWhiteSpace(instruction) && instruction.Length > 20)
                    recipe.Instructions.Add($"{step++}. {instruction}");
            }
        }

        return recipe.Ingredients.Any() ? recipe : null;
    }

    private bool IsRecipeUrl(string url) =>
        url.Contains("/recipes/") &&
        !url.Contains("/recipes/category") &&
        !url.Contains("/recipes/course") &&
        !url.EndsWith("/recipes/");

    private string CleanText(string text)
    {
        if (string.IsNullOrWhiteSpace(text)) return string.Empty;
        text = HtmlEntity.DeEntitize(text);
        text = Regex.Replace(text, @"\s+", " ");
        return text.Trim();
    }

    private string GetJsonString(JsonElement element, string propertyName) =>
        element.TryGetProperty(propertyName, out var prop) ? prop.GetString() ?? "" : "";

    private int ExtractNumber(string text)
    {
        if (string.IsNullOrWhiteSpace(text)) return 0;
        var match = Regex.Match(text, @"\d+");
        return match.Success && int.TryParse(match.Value, out var number) ? number : 0;
    }

    #endregion
}

 

With the objects and service class in place, we can expose this functionality through a new agent -the Nutrition Agent.

~

Creating the Nutrition Agent and Design Considerations

This purpose of this agent is to allow people to ask for recipes that support dietary requirements when training with the 5 x 5 weight training programme.

The Nutrition Agent acts as a window into the capabilities of the Nutrition Service and makes these capabilities accessible from a natural language perspective.

Our service class does most of the heavy lifting.

You could argue the agent seems like an extra layer of abstraction over the core functionality in the Nutrition Service class.

This is a valid point.  Not everything requires an AI solution.

You might not need to interact with a large language model or reason over code using natural language.  In this example, we want to use natural language and assign an agent with task.

By exposing these features as an agent, it can be repurposed, leveraged as a function tool within its own right and easily integrated with large language models or other AI solutions.

The definition of the Nutrition Agent:

public class NutritionAgent
{
    [Description("Searches for high protein recipes optimized for strength training. " +
                 "Returns recipes with detailed nutrition information. " +
                 "Best for finding recipes for 5x5 training, muscle building, or post-workout meals.")]
    public static async Task<List<string>> SearchRecipesAsync([Description("Search term (e.g., 'chicken', 'beef', 'protein', 'breakfast')")] string searchTerm,
                                             [Description("Minimum protein grams per serving (default 20)")] int minProtein = 20,
                                             [Description("Maximum number of recipes to return (default 3)")] int maxResults = 3)
    {
        var service = new NutritionService();

        Console.WriteLine($"Searching for recipes with term '{searchTerm}' and minimum {minProtein}g protein...");

        var recipes = await service.SearchRecipesAsync(searchTerm);

        var suitableRecipes = new List<Recipe>();

        // Scrape each recipe for details
        foreach (var recipeUrl in recipes)
        {
            Console.WriteLine($"Scraping recipe details from {recipeUrl}...");
            var recipe = await service.ScrapeRecipeAsync(recipeUrl);

            if(suitableRecipes.Count >= maxResults)
            {
                break;
            }

            if (recipe != null && recipe.Nutrition.ProteinGrams >= minProtein)
            {
                Console.WriteLine($"Found suitable recipe: {recipe.Title} with {recipe.Nutrition.ProteinGrams}g protein.");
                suitableRecipes.Add(recipe);
            }
        }

        return suitableRecipes.Select(r => JsonSerializer.Serialize(r)).ToList();
    }
}

 

We could create an agent with following the code:

AIAgent nutritionAgent = new OpenAIClient(apiKey)
  .GetChatClient(model)
  .CreateAIAgent(instructions: "You can fetch recipes and nutrition data.",
             name: "IronMind AI", null,
             tools [AIFunctionFactory.Create(NutritionAgent.SearchRecipesAsync)]
);

 

But we want to see how to advertise the Nutrition Agent as an additional function tool from the main personal trainer agent.

~

Implementing the Nutrition Agent as a Function Tool

We can extend our original personal trainer agents intelligence by adding the Nutrition Agent as a function tool.

This is done by using the AsAIFunction method.

We can see this here:

AIAgent nutritionAgent = new OpenAIClient(apiKey)
                   .GetChatClient(model)
                   .CreateAIAgent(instructions: "You can fetch recipes and nutrition data.",
                                  name: "IronMind AI", null,
                                  tools: [AIFunctionFactory.Create(NutritionAgent.SearchRecipesAsync)]
                                 );


AIAgent agent = new OpenAIClient(apiKey)
           .GetChatClient(model)
           .CreateAIAgent(instructions: "You are a personal trainer." +
                                        "You can answer questions related to health, fitness and 5x5 stronglift. " +
                                        "You shuld only use function tools in the PersonalTrainerAgent or NutritionAgent.",
                          name: "IronMind AI", null,
                          tools: [AIFunctionFactory.Create(PersonalTrainerAgent.GetNextAvailableDate),
                                 AIFunctionFactory.Create(PersonalTrainerAgent.CancelAppointment),
                                 AIFunctionFactory.Create(PersonalTrainerAgent.BookAppointment),
                                 AIFunctionFactory.Create(PersonalTrainerAgent.ListBookedAppointments),
                                 nutritionAgent.AsAIFunction()]
                         );

 

In the above code, we create the nutrition agent.  Next, when the personal trainer agent is created, we pass in the Nutrition Agent but invoke the AsAIFunction method.

This lets you quickly extend the original agents capabilities.

You can use this pattern to build multiple agents, each with different responsibilities and daisy chain capabilities based on your requirements.

~

Testing Iron Mind AI Agent New Capabilities

We can now test the AI agent capabilities in a console application.

static async Task Main(string[] args)
{

    AIAgent nutritionAgent = new OpenAIClient(apiKey)
                      .GetChatClient(model)
                      .CreateAIAgent(instructions: "You can fetch recipes and nutrition data.",
                                     name: "IronMind AI", null,
                                     tools: [AIFunctionFactory.Create(NutritionAgent.SearchRecipesAsync)]
                                    );


    AIAgent agent = new OpenAIClient(apiKey)
            .GetChatClient(model)
            .CreateAIAgent(instructions: "You are a personal trainer." +
                                         "You can answer questions related to health, fitness and 5x5 stronglift. " +
                                         "You should only use function tools in the PersonalTrainerAgent or NutritionAgent.",
                           name: "IronMind AI", null,
                           tools: [AIFunctionFactory.Create(PersonalTrainerAgent.GetNextAvailableDate),
                                  AIFunctionFactory.Create(PersonalTrainerAgent.CancelAppointment),
                                  AIFunctionFactory.Create(PersonalTrainerAgent.BookAppointment),
                                  AIFunctionFactory.Create(PersonalTrainerAgent.ListBookedAppointments),
                                  nutritionAgent.AsAIFunction()]
                          );


    await RunChatLoopWithThreadAsync(agent);
}

 

First, we supply a prompt in natural language askin get me 3 recipes with at least 20g of protein in each.

The agent automatically maps the intent to our new nutrition service and attempts search for recipes:

After a few moments, 3 recipes are found:

Next, the agent provides us with the ingredients and cooking instructions:

### 1. Mushroom Stew
- **Protein:** 34g
- **Calories:** 580
- **Carbs:** 87g
- **Fat:** 9g
- **Servings:** 2
 
**Ingredients:**
- 250g mixed mushrooms
- 4 spring onions
- 2 carrots (160g)
- 5cm piece of ginger
- Olive oil
- 150g self-raising flour
- 1 x 400g tin of black beans
- 1 x 225g tin of sliced water chestnuts
- 160g beansprouts
- 2 tablespoons gochujang paste
- 150g silken tofu
- Optional: extra virgin olive oil
 
**Instructions:**
1. Fry mushrooms until nutty, then set aside.
2. Cook spring onions, carrots, and ginger with oil for 5 minutes.
3. Make dumpling dough and form balls.
4. Add beans, water chestnuts, and dumplings to the pan, cover, and cook for 10 minutes.
5. Season, add tofu, and drizzle with extra virgin olive oil before serving.
 
---
 
### 2. Toad in the Hole
- **Protein:** 27g
- **Calories:** 677
- **Carbs:** 44g
- **Fat:** 44g
- **Servings:** 4
 
**Ingredients:**
- Sunflower oil
- 8 higher-welfare sausages
- 4 sprigs of fresh rosemary
- 2 large red onions
- 2 cloves of garlic
- 2 knobs of unsalted butter
- 6 tablespoons balsamic vinegar
- 1 level tablespoon vegetable stock powder or cube
- **Batter:**
  - 285ml milk
  - 115g plain flour
  - 3 large free-range eggs
 
**Instructions:**
1. Whisk batter ingredients and set aside.
2. Heat oil in a baking tin in a hot oven.
3. Add sausages and brown, then pour batter over.
4. Bake until golden and crisp.
5. For gravy, fry onions and garlic, add balsamic vinegar and stock, simmer, and serve.
 
---
 
### 3. Jamie's Classic Family Lasagne
- **Protein:** 28g
- **Calories:** 420
- **Carbs:** 35g
- **Fat:** 18g
- **Servings:** 12
 
**Ingredients:**
- 2 sprigs of fresh rosemary
- 100g higher-welfare smoked streaky bacon
- Olive oil
- 1kg quality minced beef
- 1kg higher-welfare minced pork
- 4 carrots
- 2 onions
- 4 sticks of celery
- 2 heaped tablespoons tomato purée
- 4 x 400g tins plum tomatoes
- 350g dried lasagne sheets
- **White Sauce:**
  - 150g mature Cheddar cheese
  - 2 medium leeks
  - 2 fresh bay leaves
  - 4 tablespoons plain flour
  - 1 litre semi-skimmed milk
  - 1 whole nutmeg, for grating
 
**Instructions:**
1. Fry rosemary and bacon, then add minced meats.
2. Add chopped vegetables and cook until softened.
3. Mix in tomatoes and simmer until thickened.
4. Make white sauce by cooking leeks and then adding flour and milk.
5. Layer lasagne in a dish, top with cheese, and bake until golden.
 
These recipes provide ample protein and are nutritious options for a healthy diet! Enjoy your cooking!

 

Perfect.

We have given our original agent a new capability.  It can now manage bookings and handle nutrition requests.

~

Demo

In this demo, we can see the above in action.  We supply the prompt and the AI agent automatically invokes our new nutrition capabilities.

~

Summary

In this blog post, we’ve seen how agents can be advertised as function tools to another agent.

We saw how to extend or Iron Mind AI personal trainer agent and give it access to capabilities contained within another agent.

You might use this pattern to isolate behaviours and responsibilities during your AI agent development.

In the next blog post, we’ll look at how to add human-in-the-loop approval.

You might want to implement this prior to an agent invoking a specific function tool.  Useful for medium to high-risk use cases.

Stay tuned.

~

Like What You’ve Read?

Enjoy what you’ve read, have questions about this content, or would like to see another topic? Drop me a note below.

You can schedule a call using my Calendly link to discuss consulting and development services.

~

Further Reading and Resources

You can find more blogs in this series here and other resources to help you on your agentic AI journey.

More from this series

Microsoft Learn

~

JOIN MY EXCLUSIVE EMAIL LIST
Get the latest content and code from the blog posts!
I respect your privacy. No spam. Ever.
Read the whole story
alvinashcraft
6 hours ago
reply
Pennsylvania, USA
Share this story
Delete

The False Comfort of the "Happy Path": Decoupling Your Services

1 Share

Tiger Data Launches Agentic Postgres on Tiger Cloud
Tiger Data just made Postgres ready for the AI-native era with Agentic Postgres, now free to try. With forkable databases for instant, zero-copy branches, hybrid search and APIs that let agents talk directly to your apps, it removes the complexity of building with AI. Instead of stitching together multiple tools, developers get a unified Postgres based stack this is faster, safer, and cheaper. Whether using Claude, Cursor, or custom agents, Tiger Cloud gives AI the infrastructure to branch, learn, and build safely. Start building for free!

Augment Code's new guide, The Engineering Leader AI Imperative, features real frameworks to help you lead your engineering team to systematic transformation, including 30% faster PR velocity, 40% reduction in merge times, and 10x task speed-ups across teams. Learn from CTOs at Drata, Webflow, and Tilt who've scaled AI across 100+ developer teams. Read for free.

Let's be honest: We've all written this code.

It's Monday morning, you have a deadline, and you need to implement a user registration feature. It's simple enough: save the user, send a welcome email, and track the signup in your analytics dashboard.

You write this:

public class UserService(
        IUserRepository userRepository,
        IEmailService emailService,
        IAnalyticsService analyticsService)
{
    public async Task RegisterUser(string email, string password)
    {
        var user = new User(email, password);
        await userRepository.SaveAsync(user);

        // 1. Directly coupled to email service (external API)
        await emailService.SendWelcomeEmail(user.Email);

        // 2. Directly coupled to analytics (this could be an external API)
        await analyticsService.TrackUserRegistration(user.Id);

        // What if we need to add more features?
        // This method will keep growing...
    }
}

It looks clean. It's readable. It works on your machine.

But this method is a ticking time bomb.

It assumes the "Happy Path" is the only path. It assumes the network is reliable, the email provider is up, and the analytics API is fast. In production, none of these are guaranteed.

Thinking further, I'm sure you can imagine similar code in your own projects. It might not be this exact scenario, but the pattern is common: a single method that orchestrates multiple side effects in a linear fashion.

Let's break down why this code is dangerous and how we can refactor it into a robust, event-driven architecture.

The Hidden Dangers of the "God Method"

There are three major issues hiding in those ten lines of code.

1. Temporal Coupling (Latency)

When a user clicks "Register," they have to wait for:

  1. The Database +
  2. The SMTP Server +
  3. The Analytics API

If your analytics provider is having a bad day and takes 3 seconds to respond, your user waits 3 seconds. You are punishing your user for the slowness of a background system they don't even care about.

2. The Partial Failure State

This is the most critical risk. Imagine this scenario:

  1. SaveAsync(user) succeeds. The user is in the DB.
  2. SendWelcomeEmail succeeds. The user gets an email.
  3. TrackUserRegistration throws a 503 Service Unavailable.

What happens now? If you wrap this in a transaction and rollback, you have deleted the user from the DB... but you already sent them a welcome email. The user tries to log in, but they don't exist. Now what?

If you don't rollback, you have a user in your system that is missing from your analytics. You have data inconsistency.

3. Violation of Single Responsibility (SRP)

You might argue that because we are using interfaces (IEmailService), we are decoupled. That is true for implementation details, but false for orchestration.

The UserService currently has two reasons to change:

  1. Core Domain Logic: "We now require a username in addition to email."
  2. Notification Policy: "Marketing wants to send an SMS in addition to the Email."

The UserService should strictly be responsible for the state change (creating the user). It should not be responsible for orchestrating the side effects of that change.

Level 1: Logical Decoupling with Domain Events

The first step to fixing this is to invert the control. Instead of the UserService commanding other services to do things, it should simply announce that something happened.

We can use Domain Events to achieve this.

Here is the refactored UserService:

public class UserService(
        IUserRepository userRepository,
        IDomainEventDispatcher dispatcher,
        IUnitOfWork unitOfWork)
{
    public async Task RegisterUser(string email, string password)
    {
        // 1. Create the User Entity
        var user = new User(email, password);

        // 2. Capture the side effect as an event object
        var userRegisteredEvent = new UserRegisteredEvent(user.Id, user.Email);

        // 3. Add the entity to the repository
        await userRepository.AddAsync(user);

        // 4. Dispatch the event (Assuming in-process dispatching here for simplicity)
        // Note: Handlers for Email and Analytics are now completely separate classes.
        await dispatcher.Dispatch(userRegisteredEvent);

        await unitOfWork.SaveChangesAsync();
    }
}

The UserService is now stable. Adding a "Loyalty Points" feature later doesn't require touching this method. You just add a new handler for the UserRegisteredEvent.

However, we haven't solved the reliability problem yet. If the process crashes immediately after Dispatch but before SaveChangesAsync completes, we might send an email for a user that failed to save. Or, if we save first and dispatch later, we might save the user but lose the event if the server crashes.

Level 2: Reliability with the Outbox Pattern

To fix this, we need Atomicity. Atomicity means that a set of operations either all succeed or all fail together.

We need to guarantee that if the User is saved, the UserRegisteredEvent is also saved.

Enter the Outbox Pattern.

Instead of publishing the event immediately to a message bus, we save the event to an OutboxMessages table in the same database transaction as the user.

Here is the complete implementation logic:

public async Task RegisterUser(string email, string password)
{
    // 1. Create the Domain Event
    var user = new User(email, password);
    var domainEvent = new UserRegisteredEvent(user.Id, user.Email);

    // 2. Open a Transaction
    using var transaction = dbContext.Database.BeginTransaction();

    try
    {
        // 3. Save the User to the Users Table
        dbContext.Users.Add(user);

        // 4. Serialize the Event and Save to Outbox Table
        var outboxMessage = new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Type = nameof(UserRegisteredEvent),
            Content = JsonSerializer.Serialize(domainEvent),
            OccurredOn = DateTime.UtcNow,
            ProcessedOn = null // Null means it hasn't been handled yet
        };

        dbContext.OutboxMessages.Add(outboxMessage);

        // 5. Commit BOTH changes atomically
        await dbContext.SaveChangesAsync();
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

Now, a background worker (running in a separate process) polls the OutboxMessages table. It picks up the message and publishes it to your message bus (RabbitMQ, Azure Service Bus, etc.).

If the email service is down, the background worker just retries later. We have achieved At-Least-Once delivery.

Level 3: Distributed Consistency with Sagas

The Outbox pattern is perfect for side effects (fire-and-forget actions like emails). But what if the subsequent action is mandatory?

Scenario: When a user registers, we must create a crypto-wallet for them in the WalletService. If the wallet creation fails (e.g., due to regulations), we cannot allow the user to exist in our system.

We can't just "retry later" if the WalletService says "Fraud Detected." We need to undo the user creation.

This is a distributed transaction, and we handle it with the Saga Pattern. A Saga coordinates a series of steps. If one fails, it executes Compensating Transactions to undo the previous work.

Here is how the failure scenario looks when using a Choreography-based Saga:

A Saga Sequence Diagram showing UserService creating a user, WalletService attempting to create a wallet, failing, and UserService deleting the user as a compensation action.

Here's the step-by-step breakdown of the flow:

  1. UserService: Creates User → Publishes UserCreated
  2. WalletService: Listens to UserCreated → Tries to create wallet
    • Failure: Wallet creation fails
    • Action: Publishes WalletCreationFailed
  3. UserService: Listens to WalletCreationFailedDeletes/Deactivates the User

This ensures Eventual Consistency. The system might be inconsistent for a few seconds (the user exists without a wallet), but it will eventually settle into a valid state (the user is removed).

Summary: A Heuristic for Decision Making

You don't need Sagas for everything. Over-engineering is just as bad as tight coupling. Use this simple rule of thumb:

  1. Is it a simple notification? (Email, Analytics, Cache Invalidation)
    • Use Domain Events + Outbox. It's okay if it happens 5 seconds later.
  2. Is it a critical business dependency? (Payments, Inventory, Account Status)
    • Use a Saga. If step B fails, step A must be reverted.

Coupling isn't just about code structure. It's about understanding and managing failure boundaries. If your Analytics Service goes down, it shouldn't prevent a user from registering. Build your systems to survive the unhappy path.

Hope this was helpful.

See you next week.




Read the whole story
alvinashcraft
6 hours ago
reply
Pennsylvania, USA
Share this story
Delete

💭Claude in Azure, the .NET Way: elbruno.Extensions.AI.Claude v0.1.0-preview.2

1 Share

⚠ This blog post was created with the help of AI tools. Yes, I used a bit of magic from language models to organize my thoughts and automate the boring parts, but the geeky fun and the 🤖 in C# are 100% mine.

elbruno.Extensions.AI.Claude just landed on NuGet with dual authentication support, polished samples, and drop-in compatibility with Microsoft.Extensions.AI. Here’s a fast tour so you can start shipping Azure+Claude powered experiences immediately.

Highlights

  • Works with Claude Sonnet 4.5, Haiku 4.5, and Opus 4.1 hosted in Azure AI Foundry
  • Implements IChatClient, so it plugs directly into any Microsoft.Extensions.AI pipeline
  • Supports both DefaultAzureCredential (managed identity, dev tools, service principals) and API key auth paths
  • Samples for classic responses + streaming straight from the repo

Install Once

dotnet add package elbruno.Extensions.AI.Claude --version 0.1.0-preview.2

Sample 1 Default Azure Credentials

Keep secrets in Azure and let DefaultAzureCredential figure out the right token.

using Azure.Identity;
using elbruno.Extensions.AI.Claude;
using Microsoft.Extensions.AI;

var endpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_CLAUDE_ENDPOINT")!);
var deployment = Environment.GetEnvironmentVariable("AZURE_CLAUDE_MODEL") ?? "claude-haiku-4-5";

var client = new AzureClaudeClient(endpoint, deployment, new DefaultAzureCredential());

var reply = await client.CompleteAsync(new List<ChatMessage>
{
    new(ChatRole.System, "Respond in bullet points."),
    new(ChatRole.User, "Give me two shipping ideas for a Claude-powered agent."),
});

Console.WriteLine(reply.Message.Text);

Minimal config:

set AZURE_CLAUDE_ENDPOINT=https://<resource>.services.ai.azure.com/anthropic/v1/messages
set AZURE_CLAUDE_MODEL=claude-haiku-4-5

Sample 2 API Key Mode

Need to run outside Azure or keep things lab-simple? Use the API key constructor.

using elbruno.Extensions.AI.Claude;
using Microsoft.Extensions.AI;

var configuration = new ConfigurationBuilder()
    .AddUserSecrets<Program>()
    .AddEnvironmentVariables()
    .Build();

var endpoint = new Uri(configuration["AZURE_CLAUDE_ENDPOINT"]!);
var deployment = configuration["AZURE_CLAUDE_MODEL"] ?? "claude-sonnet-4-5";
var apiKey = configuration["AZURE_CLAUDE_APIKEY"]!; // store in Key Vault or user secrets

var client = new AzureClaudeClient(endpoint, deployment, apiKey);

await foreach (var chunk in client.CompleteStreamingAsync(new List<ChatMessage>
{
    new(ChatRole.System, "Keep outputs under 30 words."),
    new(ChatRole.User, "Pitch a 1-line elevator speech for this package."),
}))
{
    if (!string.IsNullOrEmpty(chunk.Text))
    {
        Console.Write(chunk.Text);
    }
}

Load secrets in dev (user secrets shown; swap for Key Vault in prod):

dotnet user-secrets init
dotnet user-secrets set AZURE_CLAUDE_ENDPOINT "https://<resource>.services.ai.azure.com/anthropic/v1/messages"
dotnet user-secrets set AZURE_CLAUDE_MODEL "claude-sonnet-4-5"
dotnet user-secrets set AZURE_CLAUDE_APIKEY "<api-key>"

Try the Repo Samples

git clone https://github.com/elbruno/elbruno-extensions-ai-claude.git
cd elbruno-extensions-ai-claude

dotnet run --project samples/elbruno.Extensions.AI.Claude.Samples                # DefaultAzureCredential flow
dotnet run --project samples/elbruno.Extensions.AI.Claude.ApiKeySample          # API key flow

Tag @elbruno if you wire this into an agentic workflow. I’d love to see what you build!”

Happy coding!

Greetings

El Bruno

More posts in my blog ElBruno.com.

More info in https://beacons.ai/elbruno






Read the whole story
alvinashcraft
6 hours ago
reply
Pennsylvania, USA
Share this story
Delete

How Do You Fix 70% Data Loss Across 1 Million Concurrent Connections?

1 Share

6 minutes to read

When your Akka.NET application starts dropping 70-80% of incoming connections in production, who do you call? That’s the situation one of our Production Support customers faced this year - and it’s exactly the kind of problem our Akka.NET Support Plans are designed to solve.

Opportunities to purchase developer expertise with a credit card are rare. That’s exactly what we offer - and I want to show you what that looks like in practice.

Click here to read the full article.

Read the whole story
alvinashcraft
6 hours ago
reply
Pennsylvania, USA
Share this story
Delete

A Fantastic Week at VSLive! Orlando 2025

1 Share
Recap of an exciting week at VSLive! Orlando 2025, featuring the latest in ASP.NET Core and DS Server technologies. Our team had the pleasure of meeting hundreds of developers, architects and technology leaders who visited our booth to learn more about the latest advancements in TX Text Control 34.0.

Read the whole story
alvinashcraft
6 hours ago
reply
Pennsylvania, USA
Share this story
Delete

Claude Code and GitHub Copilot using Claude are not the same thing

1 Share
Read the whole story
alvinashcraft
6 hours ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories