Extracting a Small Provider-Agnostic Mailing Library
Refactoring · 2026-07-11
A practical reflection on extracting a small provider-agnostic mailing library that separates application intent from delivery mechanism. The article shows how SMTP, mailto, null, and recording senders can share the same abstraction while product-specific workflows remain in the consuming applications.
Extracting a Small Provider-Agnostic Mailing Library
1. The Practical Trigger
The mailing library did not start as a separate product or as an attempt to design a general-purpose email framework. It started with a much smaller and more practical need: more than one application had to send an email-like message, but not necessarily in the same way.
The first need came from the website. A contact form needs to turn user input into a message that can be delivered reliably to a configured recipient. That is naturally a server-side concern. The user submits the form, the website validates the input, builds the message, and sends it through an SMTP server. The user should not have to think about which email client is installed locally, and the recipient address does not need to be exposed as part of the interaction.
The second need came from UCSM. The application needed a way for users to send feedback, including structured information about the context in which the feedback was created. During early development, it was useful to support this through mailto, allowing the desktop application to hand the prepared message over to the user’s default mail client. That made the feature simple to test and easy to inspect, without requiring a dedicated backend service before the product was ready for one.
At first glance, these two situations look different. One is a website contact form using server-side delivery. The other is a desktop feedback feature that can hand off to the local mail client. But underneath those differences, both features need to express the same basic intent: create a message with recipients, a subject, a body, and possibly attachments, then send it using an appropriate delivery mechanism.
That distinction became the reason for extracting the library. The reusable part was not SMTP. It was not mailto. It was the application-level concept of sending a message.
Copying similar code into each application would have been easy in the short term, but it would also have mixed application workflows with infrastructure decisions. The website would know too much about SMTP details. The desktop application would know too much about mailto URI construction. Future changes, such as adding another provider or replacing one delivery mechanism with another, would then require changes in the consuming applications rather than in one focused library.
The extraction was therefore not mainly about reducing duplicated lines of code. It was about separating a stable concept from replaceable implementations. The applications should be responsible for deciding when a message needs to be sent and what that message should contain. The mailing library should be responsible for validating that message and delivering it through the configured sender.
2. Separating Message Intent from Delivery Mechanism
Once the same kind of messaging need appeared in more than one place, the important design question was not which email technology to use. The more important question was where the application’s responsibility should end and where the delivery mechanism should begin.
In both the website and UCSM, the application knows why a message should be sent. The website knows that a visitor has submitted a contact form. UCSM knows that a user has created feedback. Those are application-level events. They belong in the consuming applications because they are part of the workflow and user experience.
The applications also know what the message should contain. A contact form message may include the sender’s name, email address, subject, and text. A feedback message may include a category, rating, description, technical context, and perhaps a structured JSON attachment. This content is also application-owned. It is specific to the feature that produced the message.
What the application should not need to own is the delivery detail. Whether the message is sent through SMTP, handed off through mailto, recorded during a test, or ignored by a null sender is a separate concern. Those choices are infrastructure decisions. They may vary between development and production, between desktop and web, or between one deployment and another.
That separation led to the central abstraction in the library: IEmailSender.
public interface IEmailSender
{
Task<EmailSendResult> SendAsync(
EmailMessage message,
CancellationToken ct = default);
}
The interface is intentionally small. It does not mention SMTP. It does not mention mailto. It does not expose provider-specific configuration. It simply says that a message can be sent, and that the attempt produces a structured result.
That smallness is important. A useful abstraction does not have to cover every feature of every possible provider. It only needs to describe the stable concept that the application depends on. In this case, the stable concept is that the application has an email message and wants someone else to handle delivery.
The sender implementations can then vary independently. An SMTP sender can connect to a configured mail server. A mailto sender can build a URI and open the user’s mail client. A recording sender can keep messages in memory for tests. A null sender can safely disable delivery. From the application’s point of view, these are different implementations of the same intent.
This also makes the dependency more honest. Without the abstraction, application code can easily become tied to a specific delivery mechanism. A contact form might start constructing SMTP messages directly. A desktop feedback feature might start building mailto links inline. That works at first, but it makes the workflow harder to change later because infrastructure details have leaked into application logic.
By depending on IEmailSender, the application keeps its focus on the message it wants to send. The composition root decides which sender implementation should be used. That means provider selection happens at the boundary of the application, rather than being scattered through the workflow.
The result is a cleaner division of responsibility: the application owns the reason and content of the message, while the mailing library owns validation, delivery, and provider-specific behavior.1
3. Keeping the Email Model Provider-Neutral
After separating message intent from delivery mechanism, the next question was what the shared email model should look like. The library needed enough structure to be useful across applications, but not so much that it became tied to one provider or one specific workflow.
The central model is EmailMessage. It represents the message the application wants to send, not the technical details of how the message will be delivered. That distinction matters. A website contact form, a desktop feedback feature, and a test sender should all be able to use the same message model without caring whether the final delivery happens through SMTP, mailto, or something else later.
The message model therefore describes common email concepts: sender, recipients, subject, body, optional HTML body, reply-to address, and attachments. These are stable concepts from the application’s point of view. They are not specific to SMTP, and they are not specific to mailto.
Supporting types such as EmailAddress and EmailAttachment help keep that model explicit. An email address is not just a raw string passed around the system. An attachment is not just a file path hidden inside provider-specific code. By giving these concepts their own types, the library makes the message structure easier to understand and easier to validate.
This is also where provider-neutral design requires some restraint. Different delivery mechanisms support different features. SMTP can handle attachments directly. mailto is much more limited. An API-based provider might support templates, tags, tracking options, or provider-specific metadata. It would be tempting to add all of those possibilities to the shared model, but that would make the abstraction less clear.
The goal of the shared model is not to expose every feature of every provider. The goal is to represent the common message intent that the applications actually need.
That means the model should stay close to the stable domain of the library:
EmailMessage
- From
- To / Cc / Bcc
- Subject
- Text body
- HTML body
- Reply-to
- Attachments
This gives the consuming applications a clear vocabulary. They can build a message without thinking about sockets, SMTP clients, URI encoding, or mail client behavior. The provider implementation then decides what it can do with that message.
The result type follows the same idea. EmailSendResult and EmailSendFailure allow the sender to report success, cancellation, validation errors, or delivery failures in a structured way. Application code does not need to catch provider-specific exceptions just to understand whether sending worked. It can inspect the result and decide how to respond in its own workflow.
A provider-neutral model also makes tests simpler. A test can create an EmailMessage, pass it to a recording sender, and verify the message that would have been sent. No SMTP server is required. No local mail client needs to open. The test is focused on application intent rather than delivery infrastructure.
This is the useful balance for a small library: the model is concrete enough to make email messages explicit, but general enough that the application is not locked to one provider. The library defines the shape of the message. The selected sender defines how that message is delivered.
4. SMTP as One Implementation, Not the Center of the Design
For the website contact form, SMTP was the natural first real delivery mechanism. A visitor fills in the form, the website validates the input, builds an email message, and sends it from the server to the configured recipient. The user does not need to open a local mail client, and the receiving address can remain part of the server-side configuration rather than being exposed in the page.
That makes SMTP a good fit for this specific workflow. The website owns the interaction with the visitor. The server owns the delivery attempt. If the message is accepted by the SMTP server, the website can show a success message. If something fails, the website can show a controlled error or log the failure for later investigation.
However, this does not mean that SMTP should become the center of the design.
It would have been easy to let the contact form depend directly on SMTP-specific types and configuration. The page model could construct a mail message, configure the SMTP host, set credentials, and send the message directly. That might look efficient at first, because all the logic is in one place. But it would also mix three different concerns: the contact-form workflow, the construction of the email message, and the infrastructure details required to deliver it.
The extracted mailing library avoids that by treating SMTP as one implementation of IEmailSender.
Contact form
-> EmailMessage
-> IEmailSender
-> SmtpEmailSender
-> SMTP server
The contact form should not need to know how the SMTP sender connects to the server.2 It should not need to know how credentials are applied, how MIME messages are created, or how provider-specific exceptions are handled. Its responsibility is to decide that a message should be sent and to provide the content of that message.
This keeps the application code focused on the user-facing workflow. The website can ask: has the form been submitted, is the input valid, what should the message say, and what should the user see afterward? The SMTP sender can handle the infrastructure concern: how the message is delivered.
The distinction becomes more valuable when the delivery mechanism changes. The website may start with one SMTP provider and later move to another. It may eventually use an API-based email provider instead of SMTP. It may need a null sender in a local development environment or a recording sender during integration tests. Those changes should not require the contact form itself to be rewritten.
That is why SMTP is useful here, but deliberately not special. It is the first production-oriented provider, not the abstraction. The abstraction remains the application’s intent to send an email message.
This also gives the configuration a natural place to live. SMTP host, port, credentials, sender address, and security settings belong at the application boundary, where services are registered and configured. They should not be scattered through the code that handles contact-form submission.
The result is a more flexible design. The website can use SMTP where SMTP is appropriate, while the rest of the application continues to depend only on the smaller and more stable concept of sending a message.
5. mailto as a Useful Desktop-Oriented Implementation
While SMTP was a natural fit for the website, UCSM had a different starting point. The feedback feature belonged to a desktop application, and during early development it was useful to let the application prepare a message and hand it over to the user’s default mail client.
That is where mailto had value.
A mailto sender is not the same kind of delivery mechanism as SMTP. It does not send the message directly from the application. Instead, it constructs a mailto URI and asks the operating system to open it using the user’s configured mail client. The user can then inspect the message, adjust it if necessary, and choose whether to send it.
That makes mailto limited, but also practical.
For an early feedback flow, this can be a good tradeoff. It avoids requiring a dedicated backend service before the product is ready for one. It avoids storing feedback server-side too early. It also makes the generated message visible to the user, which can be useful when the message includes diagnostic context or structured information about the application state.
In UCSM, the feedback workflow can prepare the message content, including the user’s description and relevant context. The mailing library can then turn that message into a mailto URI and launch the default mail client.
Feedback window
-> Feedback service
-> EmailMessage
-> IEmailSender
-> MailToEmailSender
-> default mail client
This keeps the workflow clear. UCSM owns the feedback-specific behavior: categories, ratings, selected item context, technical information, and any generated JSON payload. The mailing library owns the generic concern of turning an EmailMessage into something the configured sender can handle.
The limitations of mailto are important. It cannot be treated as a complete replacement for server-side delivery. It depends on the user having a mail client configured. It cannot reliably support attachments in the same way SMTP can. It does not guarantee that the user actually sends the message after the mail client opens. It may also behave differently across systems and clients.
Those limitations are exactly why mailto should not define the application design.
The useful design decision was to make mailto one implementation of IEmailSender, not the feedback architecture itself. That means it can be used while it is appropriate, and later replaced by another sender without rewriting the feedback workflow.
For example, an early version of UCSM can use MailToEmailSender because it is simple and transparent. A later version can use an HTTPS feedback API, or another sender implementation, once the surrounding infrastructure exists. The application still creates a message. The configured sender decides how that message is delivered.
This is the same principle as with SMTP, but applied to a desktop environment. The application should express intent. The provider should handle delivery.
By treating mailto as a provider implementation rather than a special case scattered through the UI, the design stays flexible.3 The feedback feature can evolve without being tied permanently to the first delivery mechanism that made development convenient.
6. Keeping Product Workflows Outside the Mailing Library
A small provider-agnostic library is useful only if it has clear boundaries. In this case, the mailing library should know how to represent, validate, and send an email message. It should not know why the message exists.
That distinction is important because the website contact form and the UCSM feedback feature are not the same workflow. They both result in a message being sent, but the business meaning behind each message belongs to the consuming application.
The website contact form owns concepts such as visitor name, visitor email address, article slug, subject, message text, spam prevention, and the confirmation shown after the message is submitted. Those are website concerns. The mailing library should not know that a message came from a contact form, or that it may be related to a specific article.
UCSM owns a different set of concepts. A feedback message may include a category, rating, selected item context, application version, environment information, technical context, and a structured JSON payload. Those are UCSM concerns. The mailing library should not know what a clipboard item is, what a feedback category means, or how diagnostic information should be collected.
The mailing library should receive the finished email message after those workflows have done their work.
Application workflow
-> Build feature-specific message content
-> Create EmailMessage
-> Send through IEmailSender
This keeps the direction of dependency clear. The application depends on the mailing abstraction, but the mailing library does not depend on the application. That is what makes the library reusable. It can be used from the website, from UCSM, from tests, or from another future application without carrying product-specific assumptions with it.
This also avoids a common mistake in shared libraries: letting the first consumer define too much of the design. If the mailing library had been shaped around the website contact form, it might have ended up with contact-specific concepts. If it had been shaped around UCSM feedback, it might have ended up with feedback categories, technical context, or JSON payload behavior built in. Either choice would make the library less reusable and harder to reason about.
Instead, the product-specific workflow should stay one level above the mailing library.
For the website, direct use of IEmailSender from the contact page can be appropriate because the workflow is simple. The page model validates the form, builds an EmailMessage, sends it, and displays the result.
For UCSM, an additional application-specific service may be more appropriate. A feedback service can collect the feedback details, format the message body, attach or include structured context, and then use IEmailSender only at the final delivery step.
SendFeedbackViewModel
-> FeedbackService
-> EmailMessage
-> IEmailSender
That keeps the feedback workflow explicit without forcing the mailing library to understand it.
The benefit is not only reuse. It is also clarity. When something changes in the contact form, the change belongs in the website. When something changes in UCSM feedback, the change belongs in UCSM. When something changes in how messages are delivered, the change belongs in the mailing library or in the configured sender implementation.
This separation makes the design easier to maintain because each piece has a clear reason to change. The mailing library changes when generic email-sending behavior changes. The applications change when their own workflows change.
That is the boundary that keeps the abstraction small: the library owns generic message delivery, while each application owns the meaning of the message.
7. Shared Validation and Structured Failure Results
A shared mailing abstraction is easier to use when it does more than hide the delivery mechanism. It should also make the contract around sending clear. That includes what a valid message looks like and how a failed send attempt is reported.
Without shared validation, each consuming application would have to decide for itself whether a message is complete enough to send. The website contact form might check one set of rules. UCSM feedback might check another. A test sender might accept anything. An SMTP sender might fail later with a provider-specific exception. Over time, that can lead to inconsistent behavior and errors that are discovered too late.
The mailing library avoids this by validating the generic email message before delivery. The validation rules belong in the library because they are not specific to the website or to UCSM. They describe the minimum structure required for an email-like message to make sense.
Typical validation rules include:
An EmailMessage should:
- have a sender
- have at least one recipient
- have valid recipient addresses
- have a subject
- have either a text body or an HTML body
These rules are not about the meaning of the message. They do not know whether the message came from a contact form, a feedback window, or a test. They only verify that the message is structurally valid enough for a sender to process.
This also keeps responsibility in the right place. The application still validates its own workflow. A contact form should still validate the visitor’s name, email address, subject, message text, honeypot field, and any article-related fields. UCSM should still validate the feedback category, description, rating, and technical context options. But once those workflows have produced an EmailMessage, the mailing library can validate the shared email-level requirements.
Failure reporting follows the same principle. Instead of making every caller interpret provider-specific exceptions, the sender returns a structured result. A send attempt can succeed, be cancelled, fail validation, or fail during delivery.
That distinction is useful because different failures mean different things to the consuming application. A validation failure usually points to a programming or mapping error. A cancellation may simply mean that the operation was interrupted. A delivery failure may mean that the SMTP server rejected the message, the mail client could not be opened, or some provider-specific operation failed.
By representing these outcomes explicitly, the application can respond at the right level.
SendAsync(...)
-> Success
-> Cancelled
-> ValidationFailed
-> DeliveryFailed
The caller does not need to know whether the underlying provider failed because of an SMTP exception, a URI launch failure, or some future API provider error. The provider implementation can capture those details and translate them into a result that the application understands.
This is especially helpful for user-facing workflows. The website contact form can decide whether to show a success message, a validation-related error, or a general delivery problem. UCSM can decide whether to tell the user that feedback was prepared, cancelled, or could not be handed off to the configured sender.
Structured results also improve testing. Tests can assert that a sender reports a validation failure for an incomplete message. They can verify that cancellation is handled consistently. They can check that a recording sender captures valid messages without sending anything. The result becomes part of the contract rather than an accidental side effect of one provider implementation.
The main benefit is consistency. Each provider can have its own delivery details, but all providers report outcomes through the same shape. That makes the abstraction more useful because the application does not only depend on a common method name. It also depends on common behavior.
Shared validation and structured failure results therefore make the library safer to consume. They keep application-specific validation in the applications, generic email validation in the library, and provider-specific failure details inside the sender implementations.
8. Test-Friendly Senders
One of the benefits of introducing IEmailSender is that it creates a natural seam for testing. The application can verify that it prepares the right message without sending real email, opening a local mail client, or depending on an SMTP server.
That matters because email-sending features are often awkward to test when the delivery mechanism is hardcoded. If a contact form directly creates and sends an SMTP message, a test either has to mock lower-level SMTP behavior, configure a test mail server, or avoid testing the send path properly. If a desktop feedback feature builds and opens a mailto URI directly from the UI, a test may accidentally launch the user’s mail client or become tightly coupled to URI formatting details.
A provider-agnostic sender abstraction avoids this. The application does not need to know whether the sender is real or test-oriented. It only depends on the same interface.
Application workflow
-> EmailMessage
-> IEmailSender
In production, IEmailSender may be backed by SMTP or mailto. In tests, it can be backed by a sender that records messages in memory.
That is the role of a recording sender. Instead of delivering a message, it stores the messages it receives so the test can inspect them. A test can then verify that the application created the expected recipient, subject, body, reply-to address, or attachment metadata.
Test
-> Execute workflow
-> Inspect recorded EmailMessage
This keeps the test focused on application behavior. The test does not need to prove that SMTP works. It does not need to prove that the operating system can open the default mail client. It only needs to prove that, when the user submits a contact form or sends feedback, the application produces the right message.
A null sender serves a different purpose. It implements the same interface but deliberately does nothing. That can be useful in local development, test environments, or deployments where email sending should be disabled. The important point is that disabling delivery does not require the application workflow to change. The application can still build and submit the message through IEmailSender; the configured sender decides that nothing should be delivered.
These test-friendly implementations also protect against accidental side effects. A test should not send a real email to a configured recipient. A local development run should not accidentally open a mail client every time a feedback flow is exercised. By swapping the sender implementation at the composition boundary, the behavior can be made safe without adding conditional logic to the workflow itself.
This is a useful side effect of a small abstraction. The same seam that supports multiple real providers also supports safer testing and development.
It also helps keep tests readable. A test can express its expectation in terms of the message that should have been sent, rather than the mechanics of how that message would have been delivered. That is a better level of abstraction for testing application behavior.
For example, a contact-form test can assert that one message was recorded, that it was sent to the configured contact recipient, and that the body contains the submitted message. A feedback-flow test can assert that the generated message contains the selected category, the user’s description, and the expected technical context. None of those tests require SMTP credentials or a mail client.
The result is a cleaner and safer test strategy. Real sender implementations can have their own focused tests, while application workflows can verify message intent through recording or null senders. The library therefore improves not only runtime flexibility, but also the way the surrounding applications can be tested.
9. Dependency Injection as the Provider Selection Point
A provider-agnostic abstraction is only useful if the provider can be selected somewhere clean and predictable. In this library, that selection happens through dependency injection.
The consuming application depends on IEmailSender, but it does not decide inside the workflow which concrete sender should be used. That choice belongs at the composition boundary, where services are registered and configured.
This is important because provider selection is an application configuration concern, not a business workflow concern. A contact form should not contain logic that asks whether SMTP is enabled. A feedback service should not contain scattered checks for whether mailto, SMTP, a null sender, or a recording sender should be used. Those decisions are better made once, when the application starts.
Application startup
-> Register selected IEmailSender implementation
Application workflow
-> Use IEmailSender
This keeps the workflow code simpler. The website contact form can focus on validating the submitted form, building an EmailMessage, sending it, and displaying the result. UCSM feedback can focus on collecting feedback details, building the message, and handing it to the sender. Neither workflow needs to know how the sender was selected.
The mailing library supports this by providing registration helpers for the different sender implementations. The website can register an SMTP sender. A desktop application can register a mailto sender. A test project can register a recording sender. A local or disabled environment can register a null sender.
services.AddSmtpEmailSender(options);
services.AddMailToEmailSender();
services.AddRecordingEmailSender();
services.AddNullEmailSender();
The exact sender can then vary by application, environment, or test setup without changing the consuming workflow.
This also makes the configuration more visible. SMTP host, port, credentials, sender address, and security settings belong near the service registration, not inside the contact page or feedback window. The same is true for choosing a safe sender in tests. When a test registers a recording sender, it is clear that no real email will be sent. When a local environment registers a null sender, it is clear that delivery is disabled.
That visibility matters. Hidden provider selection can make code harder to reason about because behavior changes depending on conditions buried inside the workflow. By moving provider selection to dependency injection, the application has one clear place to look when deciding how messages are delivered.4
This approach also keeps future changes less invasive. If a later version introduces an API-based sender, the consuming workflows should not need to change. The new implementation can still implement IEmailSender, and the application can choose it during service registration.
Before:
Contact form knows how to send through SMTP
After:
Contact form knows it needs to send an EmailMessage
Startup decides which sender handles delivery
That is the real benefit of dependency injection here. It is not just a framework feature. It is the place where infrastructure choices are made explicit, replaceable, and separate from the application workflow.
The result is a cleaner responsibility split. Application code describes the message. The configured sender delivers it. The composition root decides which implementation connects those two parts.
10. What the Extraction Deliberately Does Not Solve
A small library is often most valuable when it is clear about what it does not try to become. The mailing library was extracted to give applications a shared way to describe and send email messages through different delivery mechanisms. It was not extracted to become a complete email platform.
That distinction matters because email can easily grow into a much larger problem. Once a project has a reusable sender abstraction, it can be tempting to keep adding adjacent concerns: templates, queues, retries, provider-specific metadata, delivery tracking, bounce handling, unsubscribe management, campaign features, message history, and administration screens. Some of those capabilities may be useful in other contexts, but they are not part of the core problem this library was meant to solve.
The immediate need was smaller and more focused:
Application creates an EmailMessage
-> IEmailSender sends it
-> Application receives a structured result
That is the boundary.
The library should help an application express a message and send it through a configured provider. It should not decide what kind of message the application should send. It should not own contact-form behavior. It should not own UCSM feedback behavior. It should not understand article slugs, feedback categories, clipboard context, ratings, or diagnostic options.
Those concepts belong in the consuming applications.
The library also does not need to become a template engine. A future application may benefit from reusable email templates, but that is a separate concern from provider-agnostic sending. Template selection, placeholder replacement, localization, branding, and formatting rules can quickly become product-specific. Adding that too early would make the mailing library responsible for content decisions, not only delivery.
The same applies to background queues and retry policies. Reliable background delivery is a real concern in many systems, especially when messages must not be lost. But queueing introduces another set of design questions: persistence, retry intervals, poison messages, idempotency, monitoring, and operational failure handling. Those are important concerns, but they would significantly change the nature of this library.
Provider-specific features should also be treated carefully. An SMTP sender, a mailto sender, and a future API-based sender will not all support the same capabilities. Some providers may support tags, templates, custom headers, click tracking, or metadata. Adding those features directly to the shared model would make the provider-neutral abstraction less neutral. The model should remain focused on the common message intent unless there is a clear reason to extend it.
This restraint is especially important because the library is used from more than one kind of application. A website contact form and a desktop feedback feature do not need the same surrounding infrastructure. If the library grows around one use case too aggressively, the other use cases become harder to keep clean.
The useful extraction was therefore not “all email-related functionality”. It was the smaller seam where application code hands a completed message to a delivery mechanism.
The goal is not to prevent future growth. The goal is to avoid premature ownership. The mailing library should own generic message delivery. Everything else should earn its place through an actual need.
That restraint is part of the design. A small abstraction is useful because it stays close to the problem it was created to solve.
11. Lessons from the Extraction
The most important lesson from extracting the mailing library is that a useful abstraction does not start with the question “what might we need someday?” It starts with the question “what is stable here, and what is likely to vary?”
In this case, the stable concept was simple: an application sometimes needs to create an email message and ask something else to deliver it. The variable part was the delivery mechanism. The website could use SMTP. UCSM could use mailto during early development. Tests could use a recording sender. Some environments could use a null sender. A future application could use another provider entirely.
That separation made the extraction worthwhile.
The library did not need to know everything about every possible email provider. It only needed to define the shared language between the application and the delivery mechanism. EmailMessage describes what should be sent. IEmailSender describes the act of sending. EmailSendResult describes the outcome. Those concepts are small, but they are enough to keep application code independent of the chosen provider.
Another lesson is that reuse was not the only benefit. Reducing duplication is useful, but it was not the main reason for the extraction. The larger benefit was making responsibilities clearer.
The consuming application owns the workflow. It decides when a message should be sent and what the message should contain. The mailing library owns generic validation and delivery. The configured provider owns the mechanics of sending through SMTP, mailto, or another implementation.
That boundary makes the system easier to change. If the contact form changes, the website changes. If the feedback format changes, UCSM changes. If the delivery mechanism changes, the sender implementation or dependency injection registration changes. Each part has a clearer reason to change.
The extraction also made testing easier. Once email sending was represented by IEmailSender, tests no longer had to involve a real SMTP server or a local mail client. A recording sender could capture the message intent directly. That made tests safer and more focused: they could verify that the application created the right message without depending on external delivery behavior.
The result is a more durable design: small enough to understand, but useful enough to support more than one application and more than one delivery mechanism.
The broader lesson is that extraction works best when it protects the application from a decision that is likely to change. In this case, the application should not be permanently shaped around SMTP, mailto, or any other specific provider. It should be shaped around its own workflow and its intent to send a message.
That is the kind of abstraction that earns its place: small, explicit, testable, and focused on a real seam in the system.5
12. Closing
The mailing library was useful because it stayed small. It did not try to become a complete email system, and it did not try to own the workflows that produced the messages. Its purpose was narrower: give applications a shared way to express email intent, then let a configured sender decide how that message should be delivered.
That separation made the surrounding applications cleaner. The website contact form can focus on the visitor’s request, validation, and user-facing response. UCSM feedback can focus on collecting useful product-specific context. Neither feature needs to be shaped around SMTP, mailto, or any other specific provider.
The delivery mechanism can change without changing the meaning of the workflow. SMTP may be the right choice for a website. mailto may be the right choice for an early desktop feedback flow. A recording sender may be the right choice for tests. A null sender may be the right choice when delivery should be disabled. A future API-based provider may become the right choice later.
The common part is not the provider. The common part is the message.
In this case, the stable concept was simple: an application had a message to send. The variable part was how that message would leave the application.
By keeping that boundary clear, the mailing library became useful without becoming large. It made email delivery replaceable, testing safer, and application workflows easier to reason about. That is often the best kind of shared library: not one that solves every possible future problem, but one that cleanly solves the problem the applications actually have.
-
This follows the same principle discussed in “Application Services Should Orchestrate – Not Own Everything”: application code should coordinate the workflow without absorbing responsibilities that belong to more specialized components.↩
-
This is similar to the principle discussed in “Keeping Filtering in the Repository Layer”: responsibility should stay close to the component that owns the relevant knowledge. In this case, SMTP delivery details belong in the sender implementation, not in the contact-form workflow.↩
-
This relates to “When the UI Knows Too Much”: UI code should collect user intent and present feedback, but it should not become responsible for infrastructure details such as URI construction or delivery behavior.↩
-
This supports the idea discussed in “Local Reasoning Is an Architectural Superpower”: code is easier to understand when important decisions are visible in predictable places rather than scattered across unrelated workflows.↩
-
This is the opposite of the problem discussed in “When Abstractions Become Noise”. The abstraction is useful here because it protects application code from a real variation point: the delivery mechanism.↩
Want occasional updates?
Subscribe to receive occasional practical notes on .NET architecture, maintainable software, project progress, and new articles.