IEventPublisher
Возьмём пример из CsTestShop
В проекте есть интерфейс IEventPublisher, который отвечает за публикацию
событий в Kafka. На первый взгляд — удобная абстракция. Но при ближайшем
рассмотрении он нарушает Open-Closed Principle — каждое новое событие
требует изменения интерфейса и всех его реализаций.
Код
Интерфейс IEventPublisher
public interface IEventPublisher
{
Task PublishOrderCreated(OrderCreatedEvent @event);
Task PublishProductUpdated(ProductUpdatedEvent @event);
}Реализация: KafkaEventPublisher
public class KafkaEventPublisher : IEventPublisher
{
private readonly IProducer<string, string> _producer;
private readonly string _order_created_topic = Fenvironment.ORDER_CREATED_TOPIC();
private readonly string _product_updated_topic = Fenvironment.PRODUCT_UPDATED_TOPIC();
public KafkaEventPublisher()
{
var config = new ProducerConfig
{
BootstrapServers = Fenvironment.KAFKA_BOOTSTRAP_SERVERS(),
};
_producer = new ProducerBuilder<string, string>(config).Build();
}
public async Task PublishOrderCreated(OrderCreatedEvent @event)
{
var content = JsonSerializer.Serialize(@event);
var message = new Message<string, string>
{
Key = @event.OrderId.ToString(),
Value = content,
};
await _producer.ProduceAsync(_order_created_topic, message);
}
public async Task PublishProductUpdated(ProductUpdatedEvent @event)
{
var content = JsonSerializer.Serialize(@event);
var message = new Message<string, string>
{
Key = @event.ProductId.ToString(),
Value = content,
};
await _producer.ProduceAsync(_product_updated_topic, message);
}
}Использование в роутерах
[Authorize]
public class OrderRouter : Orderer.OrdererBase
{
private readonly IEventPublisher _eventPublisher;
public OrderRouter(IEventPublisher eventPublisher)
{
_eventPublisher = eventPublisher;
}
public override async Task<OrderReply> CreateOrder(CreateOrderRequest request, ServerCallContext context)
{
// ... создание заказа
OrderCreatedEvent @event = new()
{
OrderId = order.Id,
UserId = order.UserId,
TotalAmount = order.TotalAmount,
Items = order.Items.ToList(),
};
await _eventPublisher.PublishOrderCreated(@event);
return CreateReply(order);
}
}Что здесь не так?
OCP нарушен
Каждый раз, когда появляется новое событие, нужно:
- Добавить метод в интерфейс
IEventPublisher. - Добавить реализацию в
KafkaEventPublisher. - (Если есть другие реализации — добавить и там).
Это прямое нарушение Open-Closed Principle:
- Класс не закрыт для изменения — при добавлении нового события мы вынуждены менять существующий код.
- Интерфейс не открыт для расширения — нельзя добавить новое событие без модификации интерфейса.
Доказательство: документация vs реальность
В DOCUMENTATION.md упоминаются события:
order-createdproduct-updateduser-createduser-deleted
Но в коде реализованы только первые два. UserCreated и UserDeleted
закомментированы в интерфейсе или вообще отсутствуют:
// Эти события есть в документации, но не реализованы
public record UserCreatedEvent { ... }
public record UserDeletedEvent { ... }Чтобы добавить их, пришлось бы править IEventPublisher — это стоп-фактор.
Разработчик думает: “Не хочу трогать интерфейс, который используют другие
сервисы”, — и событие остаётся нереализованным.
Дублирование логики
Каждый метод в KafkaEventPublisher делает одно и то же:
- Сериализует событие в JSON.
- Создаёт сообщение с ключом.
- Публикует в топик.
Разница только в типе события, названии топика и способе получения ключа.
Как исправить?
Вариант 1: Обобщённый метод
Самый простой способ — сделать обобщённый метод и маппинг события → топик через атрибуты или конфигурацию.
// 1. Атрибут для топика
[AttributeUsage(AttributeTargets.Class)]
public class EventTopicAttribute : Attribute
{
public string Topic { get; }
public EventTopicAttribute(string topic) => Topic = topic;
}
// 2. Новый интерфейс
public interface IEventPublisher
{
Task Publish<T>(T @event) where T : class;
}
// 3. Реализация
public class KafkaEventPublisher : IEventPublisher
{
private readonly IProducer<string, string> _producer;
private readonly Dictionary<Type, (string Topic, Func<object, string> KeyExtractor)> _topic_map;
public KafkaEventPublisher()
{
var config = new ProducerConfig
{
BootstrapServers = Fenvironment.KAFKA_BOOTSTRAP_SERVERS(),
};
_producer = new ProducerBuilder<string, string>(config).Build();
_topic_map = new Dictionary<Type, (string, Func<object, string>)>
{
[typeof(OrderCreatedEvent)] = (Fenvironment.ORDER_CREATED_TOPIC(), e => ((OrderCreatedEvent)e).OrderId.ToString()),
[typeof(ProductUpdatedEvent)] = (Fenvironment.PRODUCT_UPDATED_TOPIC(), e => ((ProductUpdatedEvent)e).ProductId.ToString()),
};
}
public async Task Publish<T>(T @event) where T : class
{
if (!_topic_map.TryGetValue(typeof(T), out var mapping))
{
throw new InvalidOperationException($"No topic configured for event {typeof(T).Name}");
}
var content = JsonSerializer.Serialize(@event);
var message = new Message<string, string>
{
Key = mapping.KeyExtractor(@event),
Value = content,
};
await _producer.ProduceAsync(mapping.Topic, message);
}
}Плюсы:
- Интерфейс больше не меняется при добавлении новых событий.
- Добавление нового события требует только добавить запись в
_topic_map. - Нет дублирования кода.
Минусы:
- Всё ещё нужно менять
_topic_mapпри добавлении события.
Вариант 2: Автоматическое обнаружение через атрибуты
public interface IEventPublisher
{
Task Publish<T>(T @event) where T : class;
}
public class KafkaEventPublisher : IEventPublisher
{
private readonly IProducer<string, string> _producer;
private readonly Dictionary<Type, string> _topics;
public KafkaEventPublisher()
{
var config = new ProducerConfig
{
BootstrapServers = Fenvironment.KAFKA_BOOTSTRAP_SERVERS(),
};
_producer = new ProducerBuilder<string, string>(config).Build();
// Автоматически собираем все типы событий с атрибутом
_topics = Assembly.GetExecutingAssembly()
.GetTypes()
.Where(topic => topic.GetCustomAttribute<EventTopicAttribute>() != null)
.ToDictionary(
topic => topic,
topic => topic.GetCustomAttribute<EventTopicAttribute>()!.Topic
);
}
public async Task Publish<T>(T @event) where T : class
{
if (!_topics.TryGetValue(typeof(T), out var topic))
{
throw new InvalidOperationException($"No topic configured for event {typeof(T).Name}");
}
var content = JsonSerializer.Serialize(@event);
var message = new Message<string, string>
{
Key = GetKey(@event),
Value = content,
};
await _producer.ProduceAsync(topic, message);
}
private string GetKey<T>(T @event) where T : class
{
// Можно тоже вынести в атрибут или использовать reflection
return @event switch
{
OrderCreatedEvent e => e.OrderId.ToString(),
ProductUpdatedEvent e => e.ProductId.ToString(),
_ => Guid.NewGuid().ToString(),
};
}
}Теперь добавление нового события:
[EventTopic("user-created")]
public record UserCreatedEvent
{
public int UserId { get; init; }
public required string Email { get; init; }
}Никаких изменений в IEventPublisher или KafkaEventPublisher — всё
работает автоматически.
Вариант 3: Адаптация под существующую архитектуру
Если хочется сохранить интерфейс для совместимости, можно добавить обобщённый метод, а старые методы оставить как обёртки:
public interface IEventPublisher
{
// Новый обобщённый метод
Task Publish<T>(T @event) where T : class;
// Старые методы — для обратной совместимости
Task PublishOrderCreated(OrderCreatedEvent @event);
Task PublishProductUpdated(ProductUpdatedEvent @event);
}
public class KafkaEventPublisher : IEventPublisher
{
public async Task Publish<T>(T @event) where T : class
{
// ... общая логика
}
public async Task PublishOrderCreated(OrderCreatedEvent @event)
=> await Publish(@event);
public async Task PublishProductUpdated(ProductUpdatedEvent @event)
=> await Publish(@event);
}Это позволяет постепенно переходить на новый подход, не ломая существующий код.
Примечание: В .NET существуют и другие подходы для обработки событий, например,
IMediatorиз библиотеки MediatR или конвейеры поведения (IPipelineBehavior). Наш пример с атрибутами — не единственный и не всегда лучший способ. Он показан как одна из возможных реализаций для демонстрации OCP. В реальных проектах выбирайте подход, который лучше всего подходит под вашу архитектуру.
Что мы получили?
Мы взяли интерфейс, который был жёстко привязан к конкретным событиям, и превратили его в обобщённую абстракцию:
- Интерфейс теперь закрыт для изменений — он не меняется при добавлении новых событий.
- Система открыта для расширения — новое событие добавляется созданием нового класса с атрибутом или записью в конфигурации.
- Исчезло дублирование кода — вся логика публикации теперь в одном методе.
- Легко добавлять новые события, не боясь сломать существующие.
Итог
IEventPublisher — это классический пример нарушения OCP:
- Интерфейс расширяется с каждым новым событием.
- Все реализации (даже потенциальные) вынуждены меняться.
- Это создаёт барьер для добавления новых фич.
Исправление через обобщённый интерфейс и конфигурацию делает систему гибкой, открытой для роста и простой в поддержке.
Запомните: Если вы видите интерфейс с методами, имена которых содержат конкретные сущности (
PublishOrderCreated,PublishProductUpdated) — это красный флаг. Скорее всего, вы нарушаете OCP. Вместо этого используйте обобщённый метод и маппинг типов на топики.
Исходный код доступен в репозитории CsTestShop .