If you're new to C# and .NET, you've probably heard the term Dependency Injection (DI) thrown around a lot. It sounds intimidating, but the idea behind it is actually simple and once it clicks, it will change the way you write code forever.
In this article, we'll explain what Dependency Injection is, why it matters and then prove its power with a real, practical example: swapping a payment provider (Google Pay to Stripe) without touching a single line of business logic and then using that same technique to write clean unit tests.
Αν είσαι καινούριος στη C# και στο .NET, μάλλον έχεις ακούσει τον όρο Dependency Injection (DI) αρκετά συχνά. Ακούγεται τρομακτικό, αλλά η ιδέα από πίσω του είναι στην πραγματικότητα απλή και μόλις σου γίνει κατανοητή θα αλλάξει τον τρόπο που γράφεις κώδικα για πάντα.
Σε αυτό το άρθρο θα εξηγήσουμε τι είναι το Dependency Injection, γιατί έχει σημασία και μετά θα αποδείξουμε τη δύναμή του με ένα πραγματικό, πρακτικό παράδειγμα: αλλαγή παρόχου πληρωμών (από Google Pay σε Stripe) χωρίς να αγγίξουμε ούτε μία γραμμή business logic και μετά χρησιμοποιώντας την ίδια τεχνική για να γράψουμε καθαρά unit tests.
What Is Dependency Injection?
Τι Είναι το Dependency Injection;
Dependency Injection is a design pattern where a class does not create the objects it depends on. Instead, those objects ("dependencies") are provided, or injected, from the outside, usually through the constructor.
Instead of this:
Το Dependency Injection είναι ένα design pattern όπου μια κλάση δεν δημιουργεί τα αντικείμενα από τα οποία εξαρτάται. Αντίθετα, αυτά τα αντικείμενα ("οι εξαρτήσεις" της) παρέχονται, ή γίνονται inject, απ' έξω, συνήθως μέσω του constructor.
Αντί για αυτό:
public class CheckoutService
{
private readonly GooglePayment _payment = new GooglePayment();
public void Checkout(decimal amount)
{
_payment.ApplyPayment(amount);
}
}
You write this:
Γράφεις αυτό:
public class CheckoutService
{
private readonly IPayment _payment;
public CheckoutService(IPayment payment)
{
_payment = payment;
}
public void Checkout(decimal amount)
{
_payment.ApplyPayment(amount);
}
}
Notice the difference: CheckoutService no longer decides which payment method to use. It just says "give me something that can process a payment" and it trusts whatever is handed to it.
That "something" is defined by an interface, a contract that says what a class must be able to do, without saying how.
Πρόσεξε τη διαφορά: το CheckoutService δεν αποφασίζει πια ποια μέθοδο πληρωμής θα χρησιμοποιήσει. Απλώς λέει "δώσε μου κάτι που μπορεί να επεξεργαστεί μια πληρωμή" και εμπιστεύεται ό,τι του δοθεί.
Αυτό το "κάτι" ορίζεται από ένα interface, ένα συμβόλαιο που λέει τι πρέπει να μπορεί να κάνει μια κλάση, χωρίς να λέει πώς.
Step 1: Define the Contract
Βήμα 1: Ορισμός του Συμβολαίου
public interface IPayment
{
void ApplyPayment(decimal amount);
}
This interface is the foundation of everything. It doesn't care whether the actual payment goes through Google, Stripe, PayPal or a fake test object. It only guarantees that whatever implements it will have an ApplyPayment method.
Αυτό το interface είναι το θεμέλιο όλων. Δεν τον νοιάζει αν η πραγματική πληρωμή περνάει μέσα από Google, Stripe, PayPal ή ένα fake test object. Εγγυάται μόνο ότι ό,τι το υλοποιεί θα έχει μια μέθοδο ApplyPayment.
Step 2: Create Concrete Implementations
Βήμα 2: Δημιουργία Συγκεκριμένων Υλοποιήσεων
public class GooglePayment : IPayment
{
public void ApplyPayment(decimal amount)
{
Console.WriteLine($"Processing {amount:C} through Google Pay...");
// Google Pay SDK logic here
}
}
public class StripePayment : IPayment
{
public void ApplyPayment(decimal amount)
{
Console.WriteLine($"Processing {amount:C} through Stripe...");
// Stripe SDK logic here
}
}
Both classes fulfill the same contract (IPayment), but their internal implementation is completely different. That's the key: the consumer of the interface doesn't need to know or care about these differences.
Και οι δύο κλάσεις εκπληρώνουν το ίδιο συμβόλαιο (IPayment), αλλά η εσωτερική τους υλοποίηση είναι εντελώς διαφορετική. Αυτό είναι το κλειδί: ο καταναλωτής του interface δεν χρειάζεται να ξέρει ή να νοιάζεται για αυτές τις διαφορές.
Step 3: Wire It Up With .NET's Built-In DI Container
Βήμα 3: Σύνδεση με το Ενσωματωμένο DI Container του .NET
.NET has dependency injection built right into it. In a typical Program.cs (for a web API, console app or worker service), you register your services like this:
Το .NET έχει το dependency injection ενσωματωμένο εξαρχής. Σε ένα τυπικό Program.cs (για ένα web API, μια console εφαρμογή ή ένα worker service), καταχωρείς τις υπηρεσίες σου έτσι:
var builder = WebApplication.CreateBuilder(args);
// Register the dependency
builder.Services.AddScoped<IPayment, GooglePayment>();
builder.Services.AddScoped<CheckoutService>();
var app = builder.Build();
Now, whenever any class asks for an IPayment in its constructor, .NET's DI container will automatically hand it a GooglePayment instance. You never write new GooglePayment() yourself; the framework does it for you.
Τώρα, όποτε μια κλάση ζητήσει ένα IPayment στον constructor της, το DI container του .NET θα της δώσει αυτόματα ένα instance GooglePayment. Ποτέ δεν γράφεις μόνος σου new GooglePayment(), το framework το κάνει για σένα.
The "Wow" Moment: Swapping Google Pay for Stripe
Η Στιγμή "Wow": Αλλαγή από Google Pay σε Stripe
Here's where the real power of DI shows up. Let's say your company decides to switch payment providers from Google Pay to Stripe. Without DI, you'd have to hunt down every new GooglePayment() in your codebase and replace it manually, which is risky and error-prone.
With DI, the change is trivial. You only touch one line, in the registration section:
Εδώ φαίνεται η πραγματική δύναμη του DI. Ας πούμε ότι η εταιρεία σου αποφασίζει να αλλάξει πάροχο πληρωμών από το Google Pay στο Stripe. Χωρίς DI, θα έπρεπε να κυνηγήσεις κάθε new GooglePayment() μέσα στον κώδικά σου και να το αντικαταστήσεις χειροκίνητα, κάτι επικίνδυνο και επιρρεπές σε λάθη.
Με το DI, η αλλαγή είναι ασήμαντη. Αγγίζεις μόνο μία γραμμή, στο σημείο καταχώρησης:
// Before
builder.Services.AddScoped<IPayment, GooglePayment>();
// After
builder.Services.AddScoped<IPayment, StripePayment>();
That's it. CheckoutService doesn't change. Its constructor still just asks for an IPayment. It has no idea and doesn't need to know that the underlying implementation switched from Google Pay to Stripe. This is the essence of DI: your business logic depends on abstractions, not on concrete implementations.
Αυτό ήταν. Το CheckoutService δεν αλλάζει. Ο constructor του συνεχίζει απλώς να ζητά ένα IPayment. Δεν έχει ιδέα και δεν χρειάζεται να ξέρει ότι η υλοποίηση από κάτω άλλαξε από Google Pay σε Stripe. Αυτή είναι η ουσία του DI: η επιχειρησιακή σου λογική εξαρτάται από αφαιρέσεις, όχι από συγκεκριμένες υλοποιήσεις.
Try it yourself below: change new GooglePayment() to new StripePayment() on the last line and hit Run. Nothing else in the code needs to move.
Δοκίμασέ το μόνος σου παρακάτω: άλλαξε το new GooglePayment() σε new StripePayment() στην τελευταία γραμμή και πάτησε Run. Τίποτα άλλο στον κώδικα δεν χρειάζεται να αλλάξει.
The Second "Wow" Moment: Testing With a Mock
Η Δεύτερη Στιγμή "Wow": Testing με ένα Mock
Because CheckoutService only depends on the IPayment interface, testing it becomes incredibly easy. You don't need a real Google Pay or Stripe account to write a unit test; you can create a fake implementation just for testing purposes.
Using a mocking library like Moq:
Επειδή το CheckoutService εξαρτάται μόνο από το interface IPayment, το testing του γίνεται απίστευτα εύκολο. Δεν χρειάζεσαι πραγματικό λογαριασμό Google Pay ή Stripe για να γράψεις ένα unit test· μπορείς να φτιάξεις μια fake υλοποίηση μόνο για testing.
Χρησιμοποιώντας μια mocking βιβλιοθήκη όπως το Moq:
[Fact]
public void Checkout_Should_Call_ApplyPayment_With_Correct_Amount()
{
// Arrange
var mockPayment = new Mock<IPayment>();
var checkoutService = new CheckoutService(mockPayment.Object);
// Act
checkoutService.Checkout(100m);
// Assert
mockPayment.Verify(p => p.ApplyPayment(100m), Times.Once);
}
Here, mockPayment.Object is a fake IPayment created entirely in memory. No real payment provider is contacted, no network calls are made and the test runs in milliseconds. We're only verifying that CheckoutService behaves correctly, meaning that it calls ApplyPayment with the right amount, completely isolated from any external system.
You could also write a simple hand-made fake instead of using a mocking library:
Εδώ, το mockPayment.Object είναι ένα fake IPayment φτιαγμένο εξ ολοκλήρου στη μνήμη. Κανένας πραγματικός πάροχος πληρωμών δεν καλείται, καμία κλήση δικτύου δεν γίνεται και το test τρέχει σε milliseconds. Ελέγχουμε μόνο ότι το CheckoutService συμπεριφέρεται σωστά, δηλαδή ότι καλεί το ApplyPayment με το σωστό ποσό, εντελώς απομονωμένο από κάθε εξωτερικό σύστημα.
Θα μπορούσες επίσης να γράψεις ένα απλό χειροποίητο fake αντί να χρησιμοποιήσεις mocking βιβλιοθήκη:
public class FakePayment : IPayment
{
public decimal LastAmount { get; private set; }
public void ApplyPayment(decimal amount)
{
LastAmount = amount;
}
}
Either way, the principle is the same: because CheckoutService depends on an abstraction, we can substitute any implementation, real or fake, without changing its code.
Όποιον τρόπο και να διαλέξεις, η αρχή είναι η ίδια: επειδή το CheckoutService εξαρτάται από μια αφαίρεση, μπορούμε να αντικαταστήσουμε οποιαδήποτε υλοποίηση, πραγματική ή fake, χωρίς να αλλάξουμε τον κώδικά του.
Why This Matters
Γιατί Έχει Σημασία
To summarize what we just demonstrated:
- We only touch the piece we're replacing.
CheckoutServicenever changes, because it never depended onGooglePaymentorStripePaymentin the first place. It only knows about theIPaymentabstraction. Since the dependent component only "sees" the contract, swapping what's behind that contract doesn't ripple outward into other parts of the system. This is arguably the most important takeaway from the whole example: a change to one dependency doesn't force changes in the components that use it. - Flexibility. Swapping Google Pay for Stripe took one line of code, not a rewrite.
- Testability. We tested business logic without touching a real payment gateway.
- Separation of concerns.
CheckoutServicefocuses purely on checkout logic, not on how payments are processed. - Maintainability. New payment providers can be added in the future by simply creating a new class that implements
IPayment, with zero changes to existing code.
This is what developers mean when they say DI leads to "loosely coupled" code. Each piece of the system depends on contracts (interfaces), not on specific implementations, which makes the whole application easier to change, extend and test over time.
Για να συνοψίσουμε αυτό που μόλις δείξαμε:
- Αγγίζουμε μόνο το κομμάτι που αντικαθιστούμε. Το
CheckoutServiceδεν αλλάζει ποτέ, γιατί ποτέ δεν εξαρτήθηκε από τοGooglePaymentή τοStripePaymentεξαρχής. Ξέρει μόνο για την αφαίρεσηIPayment. Επειδή το εξαρτημένο κομμάτι "βλέπει" μόνο το συμβόλαιο, η αλλαγή του τι κρύβεται πίσω από αυτό το συμβόλαιο δεν διαχέεται προς τα έξω σε άλλα κομμάτια του συστήματος. Αυτό είναι ίσως το πιο σημαντικό συμπέρασμα από όλο το παράδειγμα: μια αλλαγή σε μία εξάρτηση δεν αναγκάζει σε αλλαγές τα κομμάτια που τη χρησιμοποιούν. - Ευελιξία. Η αλλαγή από Google Pay σε Stripe χρειάστηκε μία γραμμή κώδικα, όχι ξαναγράψιμο από την αρχή.
- Δυνατότητα testing. Δοκιμάσαμε την επιχειρησιακή λογική χωρίς να αγγίξουμε πραγματική πύλη πληρωμών.
- Διαχωρισμός αρμοδιοτήτων. Το
CheckoutServiceεστιάζει αποκλειστικά στη λογική του checkout, όχι στο πώς επεξεργάζονται οι πληρωμές. - Συντηρησιμότητα. Νέοι πάροχοι πληρωμών μπορούν να προστεθούν στο μέλλον απλώς φτιάχνοντας μια νέα κλάση που υλοποιεί το
IPayment, με μηδενικές αλλαγές στον υπάρχοντα κώδικα.
Αυτό εννοούν οι developers όταν λένε ότι το DI οδηγεί σε "χαλαρά συνδεδεμένο" (loosely coupled) κώδικα. Κάθε κομμάτι του συστήματος εξαρτάται από συμβόλαια (interfaces), όχι από συγκεκριμένες υλοποιήσεις, κάτι που κάνει ολόκληρη την εφαρμογή πιο εύκολη στην αλλαγή, την επέκταση και το testing με το πέρασμα του χρόνου.
Final Thoughts
Τελικές Σκέψεις
Dependency Injection can feel abstract when you first read about it in theory. But as this example shows, its value becomes obvious the moment you need to change something (a payment provider, a database, an email service) and realize you only have to update one place instead of hunting through your entire codebase.
Once you start designing your classes around interfaces and letting .NET's built-in DI container manage the wiring, you'll find your code becomes naturally more modular, more testable and much less painful to maintain.
Το Dependency Injection μπορεί να φαίνεται αφηρημένο όταν το διαβάζεις πρώτη φορά σε θεωρία. Όμως όπως δείχνει αυτό το παράδειγμα, η αξία του γίνεται φανερή τη στιγμή που χρειάζεται να αλλάξεις κάτι (έναν πάροχο πληρωμών, μια βάση δεδομένων, μια υπηρεσία email) και συνειδητοποιείς ότι χρειάζεται να ενημερώσεις μόνο ένα σημείο αντί να ψάχνεις σε ολόκληρο τον κώδικά σου.
Μόλις αρχίσεις να σχεδιάζεις τις κλάσεις σου γύρω από interfaces και να αφήνεις το ενσωματωμένο DI container του .NET να διαχειρίζεται τη σύνδεση, θα δεις ότι ο κώδικάς σου γίνεται φυσικά πιο modular, πιο ευέλικτος στο testing και πολύ λιγότερο επώδυνος στη συντήρηση.