SOAP feels old next to REST and JSON, but plenty of companies still run SOAP services, and .NET makes calling one surprisingly painless. In this post we'll build a small Food Trip Planner: you type in a country you want to eat your way through, and the app tells you what you need to know before booking the flight.

  • the country's code
  • the capital city (where you'll probably land and eat first)
  • the currency you'll be paying for meals in
  • the phone code, for calling ahead to book restaurants
  • the country's flag
  • a signature dish to try first

All the country data comes from the free CountryInfo SOAP service:

https://www.google.com/url?q=http://webservices.oorsprong.org/websamples.countryinfo/CountryInfoService.wso&source=gmail&ust=1793896833444000&sa=E 

The trick is that we can't get everything in one call. We first ask the service for the country's ISO code, then use that code for every other call. (The service has a FullCountryInfo method that returns everything at once, but we'll skip it on purpose, since chaining calls is the skill worth practicing.)

Let's build it.

What the finished project looks like

Here's every file we'll touch. Files marked ★ are ones we write or edit. Everything else comes from the template or is generated by Visual Studio.

FoodTripPlanner/ (solution) └── FoodTripPlanner/ (project) ├── Connected Services/ │ └── ServiceReferenceCountryInfo/ │ ├── ConnectedService.json (generated) │ └── Reference.cs (generated: the SOAP client) ├── Controllers/ │ ├── HomeController.cs (template) │ └── TripController.cs ★ ├── Models/ │ ├── ErrorViewModel.cs (template) │ └── TripInfo.cs ★ ├── Services/ │ └── TripPlannerService.cs ★ (all the SOAP calls live here) ├── Views/ │ ├── Home/ ... (template) │ ├── Shared/ │ │ └── _Layout.cshtml ★ (one nav link) │ └── Trip/ │ ├── Index.cshtml ★ (search form) │ └── Result.cshtml ★ (trip card) ├── wwwroot/ ... (template) ├── appsettings.json ★ (service URL) └── Program.cs ★ (register our service) 

Step 1: Create the MVC project

Open Visual Studio and choose Create a new project → https://www.google.com/url?q=http://ASP.NET&source=gmail&ust=1793896833444000&sa=E Core Web App (Model-View-Controller). Name it FoodTripPlanner, pick the latest .NET version, and leave Authentication on None.

Step 2: Point Visual Studio at the WSDL

A SOAP service describes itself in a WSDL file. Visual Studio reads it and generates a ready-to-use C# client for us. The WSDL lives at the service URL plus ?WSDL.

  1. In Solution Explorer, right-click the project → Add → Service Reference... (or right-click Connected Services → Add → WCF Web Service).
  2. Choose WCF Web Service and click Next.
  3. Paste in `https://www.google.com/url?q=http://webservices.oorsprong.org/websamples.countryinfo/CountryInfoService.wso?WSDL%60&source=gmail&ust=1793896833445000&sa=E and click Go.
  4. Set the namespace to ServiceReferenceCountryInfo.
  5. Click Finish.

Visual Studio installs the System.ServiceModel.* NuGet packages and generates Reference.cs. These are the parts of it we'll use:

What Name
Client class CountryInfoServiceSoapTypeClient
Endpoint enum CountryInfoServiceSoapTypeClient.EndpointConfiguration.CountryInfoServiceSoap
Currency type tCurrency, with sISOCode and sName
Methods CountryISOCodeAsync, CapitalCityAsync, CountryCurrencyAsync, CountryIntPhoneCodeAsync, CountryFlagAsync

A note on .Body: depending on how the reference was generated, each call returns a response object with the value inside .Body, like (await client.CapitalCityAsync(code)).Body.CapitalCityResult. That's the version used in this post. If IntelliSense doesn't show .Body for you, the method returns the value directly, so just use await client.CapitalCityAsync(code).

Step 3: Keep the service URL in appsettings.json

Hard-coding URLs is a habit worth avoiding. Put it in config so it can change without recompiling:

json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"CountryInfoService": {
"Url": "https://www.google.com/url?q=http://webservices.oorsprong.org/websamples.countryinfo/CountryInfoService.wso&source=gmail&ust=1793896833445000&sa=E"
}
}

Notice it's plain http. This free service doesn't do https.

Step 4: A model to collect the results

Since the answers arrive one call at a time, we need somewhere to keep them. Models/TripInfo.cs gets one property per piece of information. We fill it in as each call comes back, then hand the finished object to the view.

`csharp
namespace FoodTripPlanner.Models
{
public class TripInfo
{
public string CountryName { get; set; } = string.Empty;
public string? IsoCode { get; set; }
public string? Capital { get; set; }
public string? Currency { get; set; }
public string? CurrencyCode { get; set; }
public string? PhoneCode { get; set; }
public string? FlagUrl { get; set; }
public string? SignatureDish { get; set; }
public string? ErrorMessage { get; set; }

// True when nothing went wrong
public bool Found => string.IsNullOrEmpty(ErrorMessage);
}
}
`

Step 5: The service class that talks SOAP

This is the heart of the app. Rather than cramming SOAP calls into a controller, we'll put them in their own class. Create a Services folder and add TripPlannerService.cs:

`csharp
using System.Globalization;
using System.ServiceModel;
using FoodTripPlanner.Models;
using ServiceReferenceCountryInfo;

namespace FoodTripPlanner.Services
{
public class TripPlannerService
{
private readonly string _serviceUrl;

// Our own little bit of data: what to eat first, keyed by ISO code
private static readonly Dictionary SignatureDishes = new()
{
["CA"] = "Poutine",
["IT"] = "Neapolitan pizza",
["ZA"] = "Bobotie",
["JP"] = "Sushi",
["MX"] = "Tacos al pastor",
["FR"] = "Coq au vin",
["IN"] = "Butter chicken",
["TH"] = "Pad thai",
["GR"] = "Moussaka",
["KR"] = "Bibimbap"
};

public TripPlannerService(IConfiguration config)
{
_serviceUrl = config["CountryInfoService:Url"]
?? throw new InvalidOperationException("CountryInfoService:Url is missing from appsettings.json");
}

public async Task PlanAsync(string countryName)
{
var trip = new TripInfo { CountryName = countryName };

if (string.IsNullOrWhiteSpace(countryName))
{
trip.ErrorMessage = "Please enter a country.";
return trip;
}

// The service is case sensitive, so "south africa" has to become "South Africa"
string name = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(countryName.Trim().ToLower());
trip.CountryName = name;

// Build the client using the URL from appsettings.json
var client = new CountryInfoServiceSoapTypeClient(
CountryInfoServiceSoapTypeClient.EndpointConfiguration.CountryInfoServiceSoap,
_serviceUrl);

try
{
// Call 1: country name -> ISO code
var isoResponse = await client.CountryISOCodeAsync(name);
string code = isoResponse.Body.CountryISOCodeResult;

// An unknown country is NOT an exception, the service just returns this text
if (string.IsNullOrWhiteSpace(code) || code.StartsWith("No country found"))
{
trip.ErrorMessage = "No country found by that name";
return trip;
}
trip.IsoCode = code;

// Calls 2-5 all use the code from call 1
var capitalResponse = await client.CapitalCityAsync(code);
trip.Capital = capitalResponse.Body.CapitalCityResult;

// Currency comes back as an OBJECT (tCurrency), not a string
var currencyResponse = await client.CountryCurrencyAsync(code);
tCurrency currency = currencyResponse.Body.CountryCurrencyResult;
trip.Currency = string.IsNullOrWhiteSpace(currency?.sName) ? "None" : currency.sName;
trip.CurrencyCode = currency?.sISOCode;

var phoneResponse = await client.CountryIntPhoneCodeAsync(code);
trip.PhoneCode = phoneResponse.Body.CountryIntPhoneCodeResult;

var flagResponse = await client.CountryFlagAsync(code);
trip.FlagUrl = flagResponse.Body.CountryFlagResult;

// Our own data, also keyed by the ISO code
trip.SignatureDish = SignatureDishes.TryGetValue(code, out string? dish)
? dish
: "Ask a local!";
}
catch (Exception ex)
{
trip.ErrorMessage = "The country service could not be reached: " + ex.Message;
}
finally
{
// Always close the connection (Abort if it broke)
if (client.State == CommunicationState.Faulted)
client.Abort();
else
await client.CloseAsync();
}

return trip;
}
}
}
`

A few details worth pointing out:

  • Case sensitivity. canada returns nothing and Canada works, so ToTitleCase normalizes whatever the user types.
  • "Not found" isn't an error. The service happily returns the string "No country found by that name", so we check for it ourselves.
  • Currency is a record. Every other call returns a string, but CountryCurrency returns a tCurrency object. Grab .sName for the display name.
  • Closing the client. WCF clients hold a connection open, so close them when you're done.

If your generated client has no .Body, the calls simply become:

csharp
string code = await client.CountryISOCodeAsync(name);
trip.Capital = await client.CapitalCityAsync(code);
tCurrency currency = await client.CountryCurrencyAsync(code);
trip.PhoneCode = await client.CountryIntPhoneCodeAsync(code);
trip.FlagUrl = await client.CountryFlagAsync(code);

Step 6: Register the service in Program.cs

To get the service injected into our controller, register it. This is the template's Program.cs with one line added:

`csharp
using FoodTripPlanner.Services;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddScoped(); // ★ added

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();
`

If your template uses app.MapStaticAssets() and .WithStaticAssets(), keep those and just add the AddScoped line.

Step 7: A small controller

Because the service does the heavy lifting, the controller stays tiny. Right-click Controllers → Add → Controller → MVC Controller - Empty → TripController:

`csharp
using FoodTripPlanner.Models;
using FoodTripPlanner.Services;
using Microsoft.AspNetCore.Mvc;

namespace FoodTripPlanner.Controllers
{
public class TripController : Controller
{
private readonly TripPlannerService _planner;

public TripController(TripPlannerService planner)
{
_planner = planner;
}

// GET /Trip -> the search form
[HttpGet]
public IActionResult Index()
{
return View();
}

// POST /Trip -> call the SOAP service and show the result
[HttpPost]
[ValidateAntiForgeryToken]
public async Task Index(string countryName)
{
TripInfo trip = await _planner.PlanAsync(countryName);
return View("Result", trip);
}
}
}
`

Step 8: The search form

Create a Trip folder under Views, then add Index.cshtml:

`html
@{
ViewData["Title"] = "Plan a Food Trip";
}

🍽️ Plan a Food Trip

Where do you want to eat next? Enter a country and we'll get you ready.

Country

Plan my trip

`

Step 9: The trip card

Views/Trip/Result.cshtml shows either the error or the full trip card. Note the flag is shown as an actual image, not a link:

`html
@model FoodTripPlanner.Models.TripInfo
@{
ViewData["Title"] = "Your Food Trip";
}

Food trip to @Model.CountryName

@if (!Model.Found)
{

@Model.ErrorMessage

}
else
{

Flag of @Model.CountryName

Country code @Model.IsoCode
Land in (capital) @Model.Capital
Pay for meals in @Model.Currency (@Model.CurrencyCode)
Phone code for reservations +@Model.PhoneCode
Eat this first @Model.SignatureDish

}

Plan another trip
`

Step 10: Add it to the navigation

Open Views/Shared/_Layout.cshtml, find the <ul class="navbar-nav ..."> list and add one item:

`html

`

Step 11: Take it for a spin

Run the app, click Plan a Trip, and try a few countries:

You type Code Capital Currency Signature dish
Canada CA Ottawa Canadian Dollars Poutine
italy IT Rome Euro Neapolitan pizza
South Africa ZA Cape Town South Africa Rands Bobotie
Bouvet Island BV None Krone Ask a local!
Wakanda No country found by that name

Lowercase italy works thanks to ToTitleCase, and the made-up country gets a friendly message instead of a crash.

Here's how the calls chain together behind the scenes:

text
User types "italy"
│
▼ ToTitleCase → "Italy"
CountryISOCode("Italy") ──► "IT" (or "No country found by that name" → stop)
│
├─► CapitalCity("IT") ──► "Rome"
├─► CountryCurrency("IT") ──► tCurrency { sISOCode = "EUR", sName = "Euro" }
├─► CountryIntPhoneCode("IT") ──► "39"
├─► CountryFlag("IT") ──► "https://www.google.com/url?q=http://www.oorsprong.org/.../Italy.jpg&source=gmail&ust=1793896833445000&sa=E"
└─► SignatureDishes["IT"] ──► "Neapolitan pizza" (our own data)
│
▼
TripInfo object → Result.cshtml

Using a different SOAP service

The same recipe works for any SOAP service:

  1. Add → Service Reference with the new service's ?WSDL URL.
  2. Open Connected Services/<YourNamespace>/Reference.cs and search for class and Async( to find the client class, the EndpointConfiguration enum, and each method's parameters and return types.
  3. Put the URL in appsettings.json.
  4. Call it with the same pattern:

csharp
var client = new XxxSoapClient(XxxSoapClient.EndpointConfiguration.XxxSoap, url);
var response = await client.SomeMethodAsync(arg);
var value = response.Body.SomeMethodResult; // or: var value = await client.SomeMethodAsync(arg);
await client.CloseAsync();

  1. If a method returns an object (like tCurrency), check its properties in Reference.cs.
  2. Watch for "not found" values coming back as normal text, not just exceptions.

Wrapping up

With a generated client, calling SOAP from https://www.google.com/url?q=http://ASP.NET&source=gmail&ust=1793896833445000&sa=E Core comes down to: add the reference, create the client with a URL from config, await each method, and read the result (sometimes out of .Body). The pattern worth remembering is chaining: one call gives you a key, and that key unlocks the rest. Bon appétit! 🍝