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.
- In Solution Explorer, right-click the project → Add → Service Reference... (or right-click Connected Services → Add → WCF Web Service).
- Choose WCF Web Service and click Next.
- 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.
- Set the namespace to
ServiceReferenceCountryInfo. - 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.Bodyfor you, the method returns the value directly, so just useawait 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.
canadareturns nothing andCanadaworks, soToTitleCasenormalizes 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
CountryCurrencyreturns atCurrencyobject. Grab.sNamefor 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
{
| 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 |
}
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:
-
Add → Service Reference with the new service's
?WSDLURL. - Open
Connected Services/<YourNamespace>/Reference.csand search forclassandAsync(to find the client class, theEndpointConfigurationenum, and each method's parameters and return types. - Put the URL in
appsettings.json. - 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();
- If a method returns an object (like
tCurrency), check its properties inReference.cs. - 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! 🍝
Top comments (0)