Jest w świecie .NET naprawdę niewiele rzeczy wypuszczonych przez Microsoft, do których nigdy nie miałem podejścia. Przez ostatnie kilkanaście lat przewinęło mi się przez ręce tyle, że białych plam zostało dosłownie kilka. Jedną z nich było Aspire.
I nie wzięło się to znikąd. Powiem szczerze: kiedy Aspire wychodziło, byłem wobec niego dość sceptyczny. Nie chodziło o samą technologię, tylko o pytanie, które przy każdej nowince od Microsoftu zadaję sobie odruchowo: ile zasobów i ile czasu ta firma naprawdę zamierza w to włożyć? Bo jedno to ładne demo na konferencji, a drugie to utrzymywanie i rozwijanie kawałka ekosystemu przez lata. Każdy z nas ma w pamięci projekty z Redmond, które startowały z fanfarami, a po dwóch latach cichutko wyparowywały. Uznałem więc, że poczekam i zobaczę, czy to przeżyje.
No i przeżyło. Po czasie widać, że Microsoft w Aspire jednak zainwestował, i to nie na pół gwizdka. Za moment pokażę, jak bardzo. Do tego zobaczyłem Aspire użyte na żywo w projekcie znajomego, popatrzyłem, jak jednym dotnet run podnosi mu cały lokalny stack, i pomyślałem: no dobra, czas sprawdzić to na własnej maszynie.
Zrobiłem to dwutorowo, bo tak zwykle poznaję nowe narzędzia, dokładnie jak wtedy, gdy brałem się za Rusta. Najpierw zabawka od zera, mały projekt, w którym mogę bezkarnie wszystko popsuć. A potem prawdziwe zadanie, czyli przepięcie realnego projektu z docker-compose.yml na AppHosta. Na koniec wystawię rachunek, bo kilka rzeczy każe mi trzymać rękę na pulsie.
I jedno zastrzeżenie na wejściu, żeby było uczciwie: Aspire mam u siebie od tygodnia. To nie jest relacja człowieka, który przejechał na tym dwa lata produkcji, tylko świeże wrażenia z pierwszego zderzenia. Bierz je z tą poprawką. Zaczynajmy!
Chwila, to już nie jest .NET Aspire
Zanim wejdziemy w kod, jedno sprostowanie kalendarzowe, bo sam się na tym złapałem. To już nie jest ".NET Aspire", tylko po prostu Aspire. Od trzynastki projekt pozycjonuje się jako wieloplatformowa platforma aplikacyjna, a nie dodatek do .NET-a. Wersjonowanie oderwano od platformy i wykonano skok z 9.5 prosto na 13, żeby numerki zrównały się z .NET 10. Repo mieszka teraz pod github.com/microsoft/aspire, dokumentacja pod aspire.dev, licencja MIT, bez płatnego tieru.
Do tego doszedł AppHost pisany w TypeScripcie (już GA), a Python, Java i Go siedzą w preview. Jest też własne CLI z aspire init i aspire deploy. Ja siedzę na 13.4.6, wydanym 19 czerwca 2026, i zapamiętaj sobie ten numerek, bo wrócę do niego w sekcji o minusach.
I to jest właśnie najlepsza odpowiedź na moje dawne wątpliwości. Tyle roboty nie wkłada się w coś, co za rok ma po cichu zniknąć.
Zabawka, czyli Rabbit, Mongo i playbook Ansible
Pierwszy projekt napisałem od zera po to, żeby poznać klocki. .NET 10, Aspire SDK 13.4.6, do komunikacji Wolverine 6.16. Domena banalna i zupełnie nie o nią tu chodzi: zamówienie wpada do API, leci przez Rabbita do Workera, ląduje w Mongo i wraca do API jako potwierdzenie.
Postawiłem sobie za to jeden warunek: topologii RabbitMQ NIE deklaruje aplikacja. Żadnych exchange'y, kolejek ani bindingów zakładanych przez framework przy starcie. Robi to playbook Ansible, który uderza w management HTTP API brokera. Bo tak to zwykle wygląda na produkcji. Aplikacja siedzi na koncie, które ma prawo publikować i konsumować, a zakładanie kolejek to robota infry albo IaC. Nikt przy zdrowych zmysłach nie daje serwisowi biznesowemu praw do przemeblowania brokera.
No dobra, ale skoro topologia powstaje osobnym skryptem, to pojawia się klasyczny problem kolejności. Broker musi wstać, potem playbook musi się przemielić, a dopiero potem wolno wystartować serwisom. Normalnie ląduje to w README jako punkt trzeci: "pamiętaj, żeby najpierw odpalić skrypt X". I tu Aspire pokazuje pazur, bo pozwala wpiąć taki krok do grafu zasobów jako pełnoprawny zasób, przez AddExecutable:
var rabbitUser = builder.AddParameter("rabbitmq-username", "app");
var rabbitPassword = builder.AddParameter("rabbitmq-password", "app_password", secret: true);
var rabbitmq = builder.AddRabbitMQ("rabbitmq", rabbitUser, rabbitPassword)
.WithManagementPlugin(port: 15672);
var mongo = builder.AddMongoDB("mongo").WithMongoExpress();
var ordersDb = mongo.AddDatabase("ordersdb");
var topology = builder.AddExecutable(
name: "rabbitmq-topology",
command: "ansible-playbook",
workingDirectory: "../../ansible",
"rabbitmq-topology.yml")
.WithEnvironment("RABBITMQ_MGMT_URL", "http://localhost:15672")
.WithEnvironment("RABBITMQ_USER", rabbitUser)
.WithEnvironment("RABBITMQ_PASSWORD", rabbitPassword)
.WaitFor(rabbitmq);
builder.AddProject<Projects.ApiService>("apiservice")
.WithReference(rabbitmq).WaitFor(rabbitmq)
.WithReference(ordersDb).WaitFor(ordersDb)
.WaitForCompletion(topology)
.WithExternalHttpEndpoints();
Przeczytaj to jeszcze raz i zobacz, ile tu jest powiedziane. Playbook startuje dopiero, gdy broker jest zdrowy, bo ma WaitFor(rabbitmq). Serwis startuje dopiero, gdy playbook skończy się kodem 0, bo ma WaitForCompletion(topology). Różnicę między tymi dwoma widać jak na dłoni: jedno czeka na gotowość, drugie na zakończenie. Oba przyjechały jeszcze w Aspire 9 i są chyba najczęściej używanym przeze mnie kawałkiem tego API.
Efekt jest taki, że kolejność startu lokalnego środowiska opisuję w C#, a nie w akapicie README, którego i tak nikt nie czyta. Kompilator tego nie sprawdzi, ale przynajmniej instrukcja jest wykonywalna.
Miłym dodatkiem jest to, co dostajesz w dashboardzie bez kiwnięcia palcem. Cała ścieżka od API, przez Rabbita, Workera i Mongo, aż z powrotem do API, składa się w JEDEN spójny distributed trace. Nie konfigurowałem do tego niczego, wystarczyło standardowe AddServiceDefaults(), które wpina OpenTelemetry. Klikam w trace i widzę wszystkie skoki. Do tego Mongo Express i management UI Rabbita wiszą jako klikalne linki przy zasobach, więc nie muszę nawet pamiętać portów. Same integracje RabbitMQ i MongoDB to dosłownie linijka na każdą.
Zabawka spełniła swoje zadanie. Poznałem klocki, więc czas na coś, co boli naprawdę.
Prawdziwy projekt, czyli siedem kontenerów w jednym pliku
Drugi poligon to większy projekt, przy którym obecnie siedzę. O samym produkcie nic Ci nie powiem, bo nie moja rzecz opowiadać. Powiem tylko o infrastrukturze, bo ta akurat jest dość typowa dla każdego startupu, który zdążył trochę urosnąć.
Punkt wyjścia: docker-compose.yml z siedmioma kontenerami plus lista ręcznych kroków w README. Poczekaj, aż bazy wstaną. Odpal migracje. Wgraj schemat autoryzacji. Załóż bucket. Klasyk, który każdy nowy człowiek w zespole przechodzi w bólach pierwszego dnia. Cel był jeden: jeden AppHost.cs i jedno dotnet run.
Co realnie wjechało do tego AppHosta?
Po pierwsze, trzy różne instancje Postgresa. Nie dlatego, że ktoś lubi rozrzutność, tylko dlatego, że każda ma inny obraz i inne rozszerzenie: zwykły postgres:15-alpine, drugi z rozszerzeniem grafowym (Apache AGE), trzeci z geo (PostGIS). W Aspire to po prostu trzy razy AddPostgres(...).WithImage(...).WithHostPort(...), każdy ze swoją bazą przez AddDatabase(...). Integracja Postgresa nie robi z tego żadnego problemu.
Po drugie, porty przypięte na sztywno. WithHostPort(5432), 5433, 5434, do tego dane w wolumenach przez WithDataVolume(...) i WithLifetime(ContainerLifetime.Persistent). Domyślnie Aspire losuje porty i to jest naprawdę fajne, dopóki nie masz wokół siebie garści narzędzi, które oczekują konkretnego numeru. A mamy: pgAdmin, DBeaver i istniejące skrypty buildowe. Przypięcie portów sprawiło, że wszystko to działa dalej bez jednej zmiany, a dane przeżywają restart.
Po trzecie, kontener autoryzacji w dwóch krokach. Najpierw jednorazowy kontener z komendą migrate head, który zakłada schemat w bazie, a dopiero po jego sukcesie właściwy serve wystawiający gRPC. Czyli znowu WaitForCompletion(...), dokładnie ten sam mechanizm, którego użyłem przy playbooku Ansible w zabawce. To mi się w Aspire podoba: raz poznany klocek pasuje wszędzie.
Do tego doszedł emulator S3 i mail dev-owy (SMTP z web UI, z pakietu CommunityToolkit.Aspire). Dorzucenie kolejnego kontenera do stacku to naprawdę kilka linijek, bez ceregieli.
Miejsce, w którym magia się kończy
Bo teraz przechodzimy do części, w której trzeba było pomyśleć.
Aplikacja nie czyta connection stringów ze standardowego ConnectionStrings:*, tylko ze swojej własnej sekcji konfiguracji. Aspire wstrzykuje je pod swoje nazwy, więc trzeba je przełożyć ręcznie:
builder.AddProject<Projects.Api>("api")
.WithReference(appDb).WaitFor(appDb)
.WithEnvironment(ctx =>
{
ctx.EnvironmentVariables["Database__ConnectionStrings__Default"] =
appDb.Resource.ConnectionStringExpression;
ctx.EnvironmentVariables["Authz__Endpoint"] = ReferenceExpression.Create(
$"http://{authzEndpoint.Property(EndpointProperty.Host)}:{authzEndpoint.Property(EndpointProperty.Port)}");
});
Nie jest to trudne, ale zwróć uwagę, że w tym momencie przestajesz klikać w LEGO, a zaczynasz rozumieć, skąd Aspire bierze wartości i kiedy je rozwiązuje.
I tu nadziałem się na coś, co uważam za najważniejszą lekcję z całej tej przygody. Kontener rozmawia z kontenerem po NAZWIE zasobu w sieci Dockera, czyli postgres:5432. Ale Twoja aplikacja odpalona na hoście gada z tą samą bazą po localhost:5432. To dwa różne adresy tej samej rzeczy i Aspire za Ciebie tego nie przemyśli. Jeśli nie rozumiesz, jak działa sieć Dockera, to siedzisz i gapisz się na dwa connection stringi, które wyglądają niemal identycznie, tyle że jeden działa, a drugi nie.
No dobra, a gdzie haczyk?
Zachwyt zachwytem, ale bez rachunku byłby to folder reklamowy. Trzy rzeczy każą mi trzymać rękę na pulsie.
Po pierwsze, tempo zmian. Pamiętasz moje 13.4.6? Między 1 a 19 czerwca 2026 wyszło siedem patchy, od 13.4.0 do 13.4.6. Siedem, w trzy tygodnie. A migracja z 9.x na 13 oznacza przejście przez breaking changes wszystkich wersji pośrednich, przy czym aspire update nie zawsze poprawnie zaktualizuje .csproj. Postawisz to sobie i zapomnisz na dwa lata? Raczej nie.
Jak to boli w praktyce, dobrze pokazuje jedna zmiana. W 13.4 podniesiono domyślny obraz PostgreSQL z 17.6 na 18.3. Czyli aktualizujesz paczkę, odpalasz projekt i nagle Twój WithDataVolume() trzyma dane w formacie, którego nowy obraz nie chce tknąć. Sam się na to nie nadziałem, bo przy tygodniu stażu nie zdążyłem przez żadną taką aktualizację przejść. Ale to jest dokładnie ten rodzaj niespodzianki, który czeka na Ciebie za rok, przy okazji zupełnie niewinnego podbicia wersji paczki. I zauważ, jak to działa: żeby się przed tym uchronić, musisz wiedzieć, że Postgres nie otworzy katalogu danych zapisanego przez starszą wersję. Aspire Ci tego nie powie, a WithImage(...) jest po to, żeby nie musiało.
Po drugie, oddalenie od zależności. Im głębiej wejdziesz, tym mocniej Twój projekt zrasta się z Aspire. Sterowniki dostajesz przez pakiety integracyjne, więc możesz utknąć na starszej wersji, dopóki pakiet nie nadgoni. A gdyby ktoś kiedyś chciał odpalić projekt BEZ Aspire, robi się to niemiłe. Dlatego u mnie compose został w repo. Nazwijmy to polisą.
Po trzecie, brak hot-reloadu AppHosta. Drobiazg, ale codzienny. Każda zmiana w AppHoście to przebudowa i czekanie, a przy ciężkich kontenerach potrafi się to rozciągnąć.
I na koniec słowo o wdrożeniach, bo o to zawsze ktoś zapyta. Aspire potrafi dziś w tym pomóc, od 13.3 aspire deploy generuje deployment Helmowy, jest integracja z AKS, a aspire destroy sprząta po sobie release'y. Jeśli więc traktujesz jako aktualny zarzut głośny tekst Oskara Dudycza "Why I won't use .NET Aspire for now", to sprawdź jego datę: grudzień 2023, Preview 2. Sporo się od tego czasu zmieniło.
Tylko że mnie ten cały wątek w ogóle nie interesuje. Nie zamierzam niczego wdrażać przez Aspire. Wdrożeniami zajmuje się u mnie pipeline i to on zostaje tam, gdzie jest. Aspire wpuściłem do projektu w jednej, konkretnej roli: ma mi ułatwić życie na lokalnym środowisku. Tyle i aż tyle. I właśnie z tej perspektywy go tu oceniam.
Podsumowanie, czyli fajny klocek, który nie myśli za Ciebie
To bardzo przyjemna technologia i wracam do niej z chęcią. Lokalny setup ułatwia mocno, a to jest ten rodzaj bólu, którego zwykle nikt nie mierzy, choć zjada nam wszystkim po kilka godzin miesięcznie. Dointegrowanie kolejnego kontenera to dosłownie kilka linijek i możliwość wyrażenia zależności startowych w C# zamiast w README naprawdę robi robotę. Wchodzę w to dalej.
Ale.
Aspire to jest warstwa abstrakcji, a warstwa abstrakcji NIE zwalnia Cię z rozumienia tego, co siedzi pod spodem. Możesz mieć najpiękniejszy graf zasobów świata i najładniejszy dashboard, a i tak wysypiesz się na tym, że kontener rozmawia z kontenerem po nazwie, a Twoja aplikacja z hosta po localhost. Możesz mieć jedno dotnet run i nadal stracić dane, bo nie przypiąłeś obrazu bazy. Aspire za Ciebie tego nie przemyśli i nawet nie próbuje udawać, że przemyśli.
Jeśli znasz Dockera, sieci, kolejki i bazy, to Aspire jest świetnym mechanizmem, który oszczędza Ci klepania tego samego YAML-a piąty raz. Jeśli ich nie znasz, to daje Ci jedynie ładniejszy sposób na to, żeby się na nich przewrócić. Tyle że nie będziesz nawet wiedział, gdzie leżysz.
Mam nadzieję, że artykuł się podobał. Jak zwykle do następnego!
Cześć!




